ops: add CI/CD pipeline, a/b rolling deploy, Gitea Actions workflow
Deploy to Production / deploy (push) Failing after 10s

- .gitea/workflows/deploy.yml — push-to-main triggers rolling deploy
- scripts/deploy-bluegreen.sh — a-stack then b-stack restart; Maven runs
  in Docker (no JDK needed on runner host); Caddy reload at end
- scripts/deploy-all.ps1 — emergency manual deploy from dev machine
- infra/docker-compose.yml — a/b pairs per service; wget health checks;
  Gitea service; Prometheus/Grafana/DB ports restricted to localhost
- infra/Caddyfile — dual upstreams with health-based routing
- infra/Dockerfile.* — one per service
- infra/prometheus.yml + grafana provisioning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-14 14:01:12 +02:00
parent 5156089152
commit 82f0ac6007
72 changed files with 4715 additions and 27 deletions
@@ -8,10 +8,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Runs BDD scenarios for the IoT device connection profile feature.
* Shares step definitions with IotTransitionCucumberTest via the same glue package.
* Shares step definitions with IotTransitionCucumberIT via the same glue package.
*/
@QuarkusTest
public class IotProfileCucumberTest {
public class IotProfileCucumberIT {
@Test
public void run() {
@@ -14,7 +14,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
* Cucumber engine, so junit-platform.properties tag filters do not apply here.
*/
@QuarkusTest
public class IotTransitionCucumberTest {
public class IotTransitionCucumberIT {
@Test
public void run() {
@@ -745,6 +745,14 @@ public class IotTransitionSteps {
.isNull();
}
@Then("the response headers contain a Server-Timing header")
public void responseHeadersContainServerTiming() {
assertThat(lastResponse.getHeader("Server-Timing"))
.as("Every response must carry a Server-Timing header with app;dur measured")
.isNotNull()
.containsIgnoringCase("app;dur=");
}
@Then("the response body contains no field that echoes client request details")
public void responseBodyContainsNoFieldEchoingClientDetails() {
String body = lastResponse.asString().toLowerCase();
@@ -830,6 +838,26 @@ public class IotTransitionSteps {
.patch("/services/" + serviceIds.get(name));
}
@When("{string} sets its full IoT profile")
public void setsFullIotProfile(String name) {
actionPhase = true;
Map<String, Object> profile = new LinkedHashMap<>();
profile.put("hubUrl", "wss://hub.openhub.io/v2");
profile.put("protocols", List.of("MQTT_5_0"));
profile.put("tlsMinVersion", "1.3");
profile.put("provisioningEndpoint", "https://onboard.openhub.io/v1/provision");
profile.put("credentialFormat", "JWT");
profile.put("deviceIdField", "serialNumber");
profile.put("deviceClasses", List.of("device.class.smart-home-hub"));
profile.put("minFirmwareVersion", "2.0.0");
profile.put("firmwareUpdateUrl", "https://firmware.openhub.io/update");
profile.put("estimatedMigrationMinutes", 5);
lastResponse = asTemplateOwner()
.body(Map.of("iotProfile", profile))
.patch("/services/" + serviceIds.get(name));
currentServiceId = serviceIds.get(name);
}
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?iotReady=true is called with no (?:authentication|Authorization) header$")
public void getReplacementsIotReady() {
actionPhase = true;
@@ -844,6 +872,13 @@ public class IotTransitionSteps {
"/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?deviceClass=" + deviceClass);
}
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?protocol=([^ ]+) is called with no (?:authentication|Authorization) header$")
public void getReplacementsFilteredByProtocol(String protocol) {
actionPhase = true;
lastResponse = given().get(
"/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?protocol=" + protocol);
}
// IoT Profile Then
@Then("^GET /services/\\{openHubId\\} includes an iotProfile$")
@@ -872,4 +907,18 @@ public class IotTransitionSteps {
lastResponse.then().statusCode(200)
.body("candidates.find { it.name == '" + name + "' }.iotProfile.hubUrl", equalTo(hubUrl));
}
@Then("the iotProfile field {string} is {string}")
public void iotProfileFieldIs(String field, String value) {
given().get("/services/" + currentServiceId)
.then().statusCode(200)
.body("iotProfile." + field, equalTo(value));
}
@Then("the iotProfile.estimatedMigrationMinutes is {int}")
public void iotProfileEstimatedMigrationMinutes(int minutes) {
given().get("/services/" + currentServiceId)
.then().statusCode(200)
.body("iotProfile.estimatedMigrationMinutes", equalTo(minutes));
}
}
@@ -0,0 +1,27 @@
package org.botstandards.apix.registry.bdd;
import io.cucumber.core.cli.Main;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Runs all organisation onboarding BDD scenarios inside the Quarkus test context.
* WireMock (port 9999) is started by WireMockSetup via @BeforeAll/@AfterAll.
*/
@QuarkusTest
public class OrgOnboardingCucumberIT {
@Test
public void run() {
byte exitCode = Main.run(
"--glue", "org.botstandards.apix.registry.bdd",
"--plugin", "pretty",
"--plugin", "json:target/cucumber-report-org-onboarding.json",
"--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm",
"classpath:features/org-onboarding"
);
assertEquals(0, exitCode, "One or more org-onboarding Cucumber scenarios failed — check test output for details");
}
}
@@ -0,0 +1,918 @@
package org.botstandards.apix.registry.bdd;
import com.github.tomakehurst.wiremock.client.WireMock;
import io.cucumber.java.After;
import io.cucumber.java.Before;
import io.cucumber.java.en.Given;
import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;
import io.quarkus.arc.Arc;
import io.restassured.response.Response;
import org.botstandards.apix.registry.service.ClockService;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static io.restassured.RestAssured.given;
import static io.restassured.http.ContentType.JSON;
import static org.assertj.core.api.Assertions.assertThat;
/**
* BDD step definitions for organisation onboarding.
*
* Cucumber creates a fresh instance per scenario — instance fields are scenario-scoped.
* WireMock is started once per test run by WireMockSetup via @BeforeAll.
*/
public class OrgOnboardingSteps {
private static final String ORG_API_KEY_HEADER = "X-Org-Api-Key";
private static final String ROTATION_SECRET_HEADER = "X-Org-Rotation-Secret";
private static final String ADMIN_API_KEY_HEADER = "X-Api-Key";
private static final String ADMIN_API_KEY = "test-api-key";
private static final String DEFAULT_JURISDICTION = "DE";
private static final String DEFAULT_ORG_TYPE = "GmbH";
// ── Per-scenario state ────────────────────────────────────────────────────
private UUID orgId;
private String apiKey;
private String rotationSecret;
private String dnsToken;
private String tan;
private String oldApiKey;
private Response lastResponse;
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Before(order = 2)
public void resetWireMock() {
if (WireMockSetup.wireMock != null && WireMockSetup.wireMock.isRunning()) {
WireMockSetup.wireMock.resetAll();
}
}
// ── Setup: registry state ─────────────────────────────────────────────────
@Given("the organisation registry is empty")
public void organisationRegistryIsEmpty() {
// Handled by TestSetup @Before(order=1) — TRUNCATE includes organizations table
}
@Given("an organisation is registered with target level {string} for domain {string}")
public void orgRegisteredWithTargetAndDomain(String targetLevel, String domain) {
registerOrg("Test Organisation", "test@" + domain, DEFAULT_JURISDICTION,
DEFAULT_ORG_TYPE, domain, targetLevel);
}
@Given("an organisation is registered with target level {string} for domain {string} with name {string}")
public void orgRegisteredWithTargetDomainAndName(String targetLevel, String domain, String name) {
registerOrg(name, "test@" + domain, DEFAULT_JURISDICTION, DEFAULT_ORG_TYPE, domain, targetLevel);
}
@Given("an organisation has earned O-level {string} with target {string}")
public void orgWithEarnedAndTarget(String earnedLevel, String targetLevel) {
registerOrg("Test Organisation", "test@example.com", DEFAULT_JURISDICTION,
DEFAULT_ORG_TYPE, "example.com", targetLevel);
// Use admin API to set the earned level directly (bypasses pipeline for test setup)
given()
.contentType(JSON)
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
.body(Map.of("earnedOLevel", earnedLevel))
.when()
.patch("/organizations/" + orgId + "/earned-level")
.then()
.statusCode(200);
}
// ── Setup: WireMock DNS stubs ─────────────────────────────────────────────
@Given("the DoH TXT record {string} returns the org's dns token")
@Given("the DoH TXT record {string} now returns the org's dns token")
public void dohTxtReturnsToken(String name) {
assertThat(dnsToken).as("dns token must be known before stubbing").isNotNull();
stubDohTxt(name, "apix-token=" + dnsToken);
}
@Given("the DoH TXT record {string} returns no records")
public void dohTxtReturnsNoRecords(String name) {
stubDohTxtEmpty(name);
}
@Given("the DoH TXT record {string} returns {string}")
public void dohTxtReturnsValue(String name, String value) {
stubDohTxt(name, value);
}
@Given("the DoH TXT record {string} returns a valid DMARC policy")
public void dohTxtReturnsDmarc(String name) {
stubDohTxt(name, "v=DMARC1; p=none; rua=mailto:dmarc@example.com");
}
@Given("the DoH TXT record {string} returns a valid SPF record")
public void dohTxtReturnsSpf(String name) {
stubDohTxt(name, "v=spf1 include:_spf.example.com ~all");
}
@Given("the DoH MX record {string} returns at least one entry")
public void dohMxReturnsEntry(String domain) {
stubDohMx(domain, true);
}
@Given("the DoH MX record {string} returns no records")
public void dohMxReturnsNoRecords(String domain) {
stubDohMx(domain, false);
}
// ── Setup: WireMock GLEIF / OC stubs ─────────────────────────────────────
@Given("the GLEIF API returns an LEI {string} for name {string}")
public void gleifReturnsLei(String lei, String name) {
stubGleifFound(lei);
}
@Given("the GLEIF API returns no results for name {string}")
public void gleifReturnsEmpty(String name) {
stubGleifEmpty();
}
@Given("the OpenCorporates API returns a company match for name {string}")
public void ocReturnsMatch(String name) {
stubOcFound();
}
@Given("the OpenCorporates API returns no results for name {string}")
public void ocReturnsEmpty(String name) {
stubOcEmpty();
}
// ── Setup: WireMock security.txt stub ─────────────────────────────────────
@Given("the security.txt endpoint for {string} returns HTTP {int}")
public void securityTxtReturnsStatus(String domain, int status) {
stubSecurityTxt(domain, status);
}
// ── Setup: BSF admin actions used as Given ────────────────────────────────
@Given("a BSF admin grants a temporary level {string} expiring in {int} days")
public void adminGrantsTempLevelDays(String level, int days) {
doAdminGrantTemp(level, clockNow().plus(Duration.ofDays(days)));
}
@Given("a BSF admin grants a temporary level {string} expiring in {int} hours")
public void adminGrantsTempLevelHoursGiven(String level, int hours) {
doAdminGrantTemp(level, clockNow().plus(Duration.ofHours(hours)));
}
@Given("the owner has requested a TAN using the registered email")
public void ownerRequestsTanGiven() {
ownerRequestsTanWhen("test@example.com");
tan = lastResponse.jsonPath().getString("tan");
}
@Given("the owner has requested a TAN {int} times within the last 24 hours")
public void ownerRequestedTanNTimes(int count) {
for (int i = 0; i < count; i++) {
given()
.contentType(JSON)
.body(Map.of("email", "test@example.com"))
.when()
.post("/organizations/" + orgId + "/request-tan")
.then()
.statusCode(200);
}
}
// ── When: registration ────────────────────────────────────────────────────
@When("a service registers with target level {string}")
public void serviceRegistersWithTarget(String targetLevel) {
lastResponse = given()
.contentType(JSON)
.body(buildRegistrationBody("Test Organisation", "test@example.com",
DEFAULT_JURISDICTION, DEFAULT_ORG_TYPE, "example.com", targetLevel))
.when()
.post("/organizations")
.andReturn();
if (lastResponse.statusCode() == 201) {
saveRegistrationState(lastResponse);
}
}
@When("a service registers without a registrant name")
public void registerWithoutName() {
lastResponse = given()
.contentType(JSON)
.body(buildRegistrationBody(null, "test@example.com",
DEFAULT_JURISDICTION, DEFAULT_ORG_TYPE, "example.com", "UNVERIFIED"))
.when()
.post("/organizations")
.andReturn();
}
@When("a service registers with an invalid email")
public void registerWithInvalidEmail() {
lastResponse = given()
.contentType(JSON)
.body(buildRegistrationBody("Test Org", "not-an-email",
DEFAULT_JURISDICTION, DEFAULT_ORG_TYPE, "example.com", "UNVERIFIED"))
.when()
.post("/organizations")
.andReturn();
}
@When("a service registers without a domain")
public void registerWithoutDomain() {
lastResponse = given()
.contentType(JSON)
.body(buildRegistrationBody("Test Org", "test@example.com",
DEFAULT_JURISDICTION, DEFAULT_ORG_TYPE, null, "UNVERIFIED"))
.when()
.post("/organizations")
.andReturn();
}
@When("a service registers without a target O-level")
public void registerWithoutTarget() {
lastResponse = given()
.contentType(JSON)
.body(buildRegistrationBody("Test Org", "test@example.com",
DEFAULT_JURISDICTION, DEFAULT_ORG_TYPE, "example.com", null))
.when()
.post("/organizations")
.andReturn();
}
// ── When: verification ────────────────────────────────────────────────────
@When("the owner triggers verification")
@When("the owner triggers verification again")
public void ownerTriggersVerification() {
lastResponse = given()
.contentType(JSON)
.header(ORG_API_KEY_HEADER, apiKey)
.when()
.post("/organizations/" + orgId + "/verify")
.andReturn();
if (lastResponse.statusCode() == 200) {
updateStateFromOrgResponse(lastResponse);
}
}
@When("the owner triggers verification with an invalid api key")
public void ownerTriggersVerificationWithBadKey() {
lastResponse = given()
.contentType(JSON)
.header(ORG_API_KEY_HEADER, "wrong-key")
.when()
.post("/organizations/" + orgId + "/verify")
.andReturn();
}
// ── When: upgrade ─────────────────────────────────────────────────────────
@When("the owner requests an upgrade to target level {string}")
public void ownerRequestsUpgrade(String targetLevel) {
lastResponse = given()
.contentType(JSON)
.header(ORG_API_KEY_HEADER, apiKey)
.body(Map.of("targetOLevel", targetLevel))
.when()
.post("/organizations/" + orgId + "/request-upgrade")
.andReturn();
if (lastResponse.statusCode() == 200) {
updateStateFromOrgResponse(lastResponse);
}
}
@When("the owner requests an upgrade to target level {string} with an invalid api key")
public void ownerRequestsUpgradeWithBadKey(String targetLevel) {
lastResponse = given()
.contentType(JSON)
.header(ORG_API_KEY_HEADER, "wrong-key")
.body(Map.of("targetOLevel", targetLevel))
.when()
.post("/organizations/" + orgId + "/request-upgrade")
.andReturn();
}
// ── When: BSF admin actions ───────────────────────────────────────────────
// @Given step definitions above also match "When" and "And" keywords — no duplicates needed.
@Given("a BSF admin grants a temporary level {string} expiring in the past")
public void adminGrantsTempLevelInThePast(String level) {
doAdminGrantTemp(level, clockNow().minus(Duration.ofHours(1)));
}
@When("an unauthorised caller grants a temporary level {string}")
public void unauthorisedGrantsTempLevel(String level) {
Instant expiresAt = clockNow().plus(Duration.ofDays(1));
lastResponse = given()
.contentType(JSON)
.header(ADMIN_API_KEY_HEADER, "wrong-admin-key")
.body(Map.of(
"tempMaxOLevel", level,
"expiresAt", expiresAt.toString(),
"grantedBy", "hacker",
"reason", "unauthorized"))
.when()
.post("/organizations/" + orgId + "/temp-grant")
.andReturn();
}
@When("a BSF admin revokes the temporary grant")
public void adminRevokesTemp() {
lastResponse = given()
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
.when()
.delete("/organizations/" + orgId + "/temp-grant")
.andReturn();
}
@When("a BSF admin assigns earned level {string}")
public void adminAssignsEarnedLevel(String level) {
lastResponse = given()
.contentType(JSON)
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
.body(Map.of("earnedOLevel", level))
.when()
.patch("/organizations/" + orgId + "/earned-level")
.andReturn();
}
// ── DNS-challenge key rotation steps ─────────────────────────────────────
/** Stored per-scenario: the challenge token returned by /rotate-key-dns */
private String dnsRotationChallengeToken;
@When("the agent initiates DNS-challenge key rotation")
public void agentInitiatesDnsRotation() {
lastResponse = given()
.contentType(JSON)
.header(ROTATION_SECRET_HEADER, rotationSecret)
.when()
.post("/organizations/" + orgId + "/rotate-key-dns")
.andReturn();
if (lastResponse.statusCode() == 200) {
dnsRotationChallengeToken = lastResponse.jsonPath().getString("challengeToken");
}
}
@Given("the agent has initiated DNS-challenge key rotation")
public void agentHasInitiatedDnsRotation() {
agentInitiatesDnsRotation();
assertThat(lastResponse.statusCode()).as("DNS rotation initiation must succeed").isEqualTo(200);
}
@When("the agent initiates DNS-challenge key rotation with an invalid rotation secret")
public void agentInitiatesDnsRotationBadSecret() {
lastResponse = given()
.contentType(JSON)
.header(ROTATION_SECRET_HEADER, "wrong-rotation-secret")
.when()
.post("/organizations/" + orgId + "/rotate-key-dns")
.andReturn();
}
@Given("the agent has published the rotation challenge to dns")
public void agentHasPublishedRotationChallenge() {
assertThat(dnsRotationChallengeToken)
.as("challenge token must be known before stubbing DNS").isNotNull();
stubDohTxt("_apix-rotation.example.com", "apix-rotate=" + dnsRotationChallengeToken);
}
@Given("the DNS rotation challenge record is absent")
public void dnsRotationChallengeAbsent() {
stubDohTxtEmpty("_apix-rotation.example.com");
}
@Given("the DNS record {string} contains the wrong challenge token")
public void dnsRecordContainsWrongToken(String name) {
stubDohTxt(name, "apix-rotate=WRONG_TOKEN_VALUE");
}
@When("the agent confirms DNS-challenge key rotation")
public void agentConfirmsDnsRotation() {
oldApiKey = apiKey;
lastResponse = given()
.contentType(JSON)
.header(ROTATION_SECRET_HEADER, rotationSecret)
.when()
.post("/organizations/" + orgId + "/confirm-key-rotation-dns")
.andReturn();
if (lastResponse.statusCode() == 200) {
apiKey = lastResponse.jsonPath().getString("apiKey");
rotationSecret = lastResponse.jsonPath().getString("rotationSecret");
}
}
@When("the agent confirms DNS-challenge key rotation with an invalid rotation secret")
public void agentConfirmsDnsRotationBadSecret() {
lastResponse = given()
.contentType(JSON)
.header(ROTATION_SECRET_HEADER, "wrong-rotation-secret")
.when()
.post("/organizations/" + orgId + "/confirm-key-rotation-dns")
.andReturn();
}
@Then("the response contains the dns record name {string}")
public void responseContainsDnsRecordName(String expectedName) {
assertThat(lastResponse.jsonPath().getString("dnsRecord"))
.as("dnsRecord").isEqualTo(expectedName);
}
@Then("the response contains the challenge token and dns value format")
public void responseContainsChallengeTokenAndDnsValue() {
String token = lastResponse.jsonPath().getString("challengeToken");
String value = lastResponse.jsonPath().getString("dnsValue");
assertThat(token).as("challengeToken").isNotBlank();
assertThat(value).as("dnsValue").isEqualTo("apix-rotate=" + token);
dnsRotationChallengeToken = token;
}
@Then("the response contains an expiry timestamp")
public void responseContainsExpiryTimestamp() {
assertThat(lastResponse.jsonPath().getString("expiresAt"))
.as("expiresAt").isNotBlank();
}
// ── When: key rotation (2FA — step 1: rotation secret → TAN) ────────────────
@When("the owner initiates key rotation using the rotation secret")
public void ownerInitiatesKeyRotation() {
lastResponse = given()
.contentType(JSON)
.header(ROTATION_SECRET_HEADER, rotationSecret)
.when()
.post("/organizations/" + orgId + "/rotate-key")
.andReturn();
if (lastResponse.statusCode() == 200) {
tan = lastResponse.jsonPath().getString("tan");
}
}
@Given("the owner has initiated key rotation using the rotation secret")
public void ownerHasInitiatedKeyRotation() {
ownerInitiatesKeyRotation();
assertThat(lastResponse.statusCode()).as("rotation initiation must succeed").isEqualTo(200);
}
@When("the owner initiates key rotation using an invalid rotation secret")
public void ownerInitiatesKeyRotationWithBadSecret() {
lastResponse = given()
.contentType(JSON)
.header(ROTATION_SECRET_HEADER, "wrong-rotation-secret")
.when()
.post("/organizations/" + orgId + "/rotate-key")
.andReturn();
}
// ── When: key rotation step 2 — confirm with TAN ─────────────────────────
@When("the owner confirms key rotation with the TAN")
public void ownerConfirmsKeyRotation() {
oldApiKey = apiKey;
lastResponse = given()
.contentType(JSON)
.body(Map.of("tan", tan))
.when()
.post("/organizations/" + orgId + "/rotate-key-with-tan")
.andReturn();
if (lastResponse.statusCode() == 200) {
apiKey = lastResponse.jsonPath().getString("apiKey");
rotationSecret = lastResponse.jsonPath().getString("rotationSecret");
}
}
// ── When: assertion-only steps for key rotation ───────────────────────────
@Then("the response contains a key rotation warning with the org name")
public void responseContainsRotationWarning() {
String message = lastResponse.jsonPath().getString("message");
assertThat(message).as("rotation warning message").contains("Test Organisation");
}
@Then("a key rotation TAN has been sent")
public void keyRotationTanHasBeenSent() {
// In test mode, TAN is exposed in response body
String exposedTan = lastResponse.jsonPath().getString("tan");
assertThat(exposedTan).as("TAN must be present in test mode").isNotBlank();
tan = exposedTan;
}
// ── When: fraud report ────────────────────────────────────────────────────
@Given("the owner reports key rotation fraud using the registered email")
public void ownerReportsFraud() {
lastResponse = given()
.contentType(JSON)
.body(Map.of("email", "test@example.com"))
.when()
.post("/organizations/" + orgId + "/notify-key-rotation-fraud")
.andReturn();
}
@When("a caller reports key rotation fraud using {string}")
public void callerReportsFraud(String email) {
lastResponse = given()
.contentType(JSON)
.body(Map.of("email", email))
.when()
.post("/organizations/" + orgId + "/notify-key-rotation-fraud")
.andReturn();
}
@Then("the response confirms the fraud notification was received")
public void responseConfirmsFraud() {
String message = lastResponse.jsonPath().getString("message");
assertThat(message).as("fraud confirmation message").isNotBlank();
}
@When("a BSF admin clears the fraud lock")
public void adminClearsFraudLock() {
lastResponse = given()
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
.when()
.delete("/organizations/" + orgId + "/fraud-lock")
.andReturn();
}
@Then("the org is no longer fraud-locked")
public void orgIsNotFraudLocked() {
given()
.when()
.get("/organizations/" + orgId)
.then()
.statusCode(200);
// fraudLocked field should be false — checked by attempting rotation in the next step
}
// ── When: TAN flow ────────────────────────────────────────────────────────
@When("the owner requests a TAN using the registered email")
public void ownerRequestsTanWhenRegistered() {
ownerRequestsTanWhen("test@example.com");
if (lastResponse.statusCode() == 200) {
tan = lastResponse.jsonPath().getString("tan");
}
}
@When("the owner requests a TAN using {string}")
public void ownerRequestsTanWhen(String email) {
lastResponse = given()
.contentType(JSON)
.body(Map.of("email", email))
.when()
.post("/organizations/" + orgId + "/request-tan")
.andReturn();
if (lastResponse.statusCode() == 200) {
tan = lastResponse.jsonPath().getString("tan");
}
}
@When("the owner uses the TAN to rotate keys")
public void ownerUseTanToRotate() {
lastResponse = given()
.contentType(JSON)
.body(Map.of("tan", tan))
.when()
.post("/organizations/" + orgId + "/rotate-key-with-tan")
.andReturn();
if (lastResponse.statusCode() == 200) {
oldApiKey = apiKey;
apiKey = lastResponse.jsonPath().getString("apiKey");
rotationSecret = lastResponse.jsonPath().getString("rotationSecret");
}
}
// ── When: time ────────────────────────────────────────────────────────────
@When("time advances by {int} hours")
public void timeAdvancesHours(int hours) {
ClockService clock = Arc.container().instance(ClockService.class).get();
clock.advance(clock.now().plus(Duration.ofHours(hours)));
}
@When("time advances by {int} minutes")
public void timeAdvancesMinutes(int minutes) {
ClockService clock = Arc.container().instance(ClockService.class).get();
clock.advance(clock.now().plus(Duration.ofMinutes(minutes)));
}
// ── When: GET org ─────────────────────────────────────────────────────────
@When("the caller reads the organisation")
public void callerReadsOrg() {
lastResponse = given()
.when()
.get("/organizations/" + orgId)
.andReturn();
}
// ── Then: response assertions ─────────────────────────────────────────────
@Then("the response status is {int}")
public void responseStatusIs(int status) {
assertThat(lastResponse.statusCode())
.as("HTTP status")
.isEqualTo(status);
}
@Then("the response contains an organisation id")
public void responseContainsOrgId() {
assertThat(lastResponse.jsonPath().getString("id")).isNotBlank();
}
@Then("the response contains an api key with prefix {string}")
public void responseContainsApiKey(String prefix) {
assertThat(lastResponse.jsonPath().getString("apiKey"))
.as("apiKey").startsWith(prefix);
}
@Then("the response contains a rotation secret with prefix {string}")
public void responseContainsRotationSecret(String prefix) {
assertThat(lastResponse.jsonPath().getString("rotationSecret"))
.as("rotationSecret").startsWith(prefix);
}
@Then("the earned O-level is {string}")
public void earnedOLevelIs(String level) {
String actual = lastResponse.jsonPath().getString("earnedOLevel");
assertThat(actual).as("earnedOLevel").isEqualTo(level);
}
@Then("the effective O-level is {string}")
public void effectiveOLevelIs(String level) {
String actual = lastResponse.jsonPath().getString("effectiveOLevel");
assertThat(actual).as("effectiveOLevel").isEqualTo(level);
}
@Then("the verification status is {string}")
public void verificationStatusIs(String status) {
String actual = lastResponse.jsonPath().getString("verificationStatus");
assertThat(actual).as("verificationStatus").isEqualTo(status);
}
@Then("the verification step is {string}")
public void verificationStepIs(String step) {
String actual = lastResponse.jsonPath().getString("verificationStep");
assertThat(actual).as("verificationStep").isEqualTo(step);
}
@Then("a dns verification token is returned")
public void dnsTokenIsReturned() {
String token = lastResponse.jsonPath().getString("dnsVerificationToken");
assertThat(token).as("dnsVerificationToken").isNotBlank();
dnsToken = token; // save for subsequent WireMock stub setup
}
@Then("no dns verification token is returned")
public void noDnsTokenIsReturned() {
String token = lastResponse.jsonPath().getString("dnsVerificationToken");
assertThat(token).as("dnsVerificationToken should be null").isNull();
}
@Then("the detected LEI is {string}")
public void detectedLeiIs(String lei) {
assertThat(lastResponse.jsonPath().getString("lei"))
.as("lei").isEqualTo(lei);
}
@Then("a new api key with prefix {string} is returned")
public void newApiKeyWithPrefix(String prefix) {
assertThat(lastResponse.jsonPath().getString("apiKey"))
.as("new apiKey").startsWith(prefix);
}
@Then("a new rotation secret with prefix {string} is returned")
public void newRotationSecretWithPrefix(String prefix) {
assertThat(lastResponse.jsonPath().getString("rotationSecret"))
.as("new rotationSecret").startsWith(prefix);
}
@Then("the old api key is no longer valid")
public void oldApiKeyIsInvalid() {
assertThat(oldApiKey).as("oldApiKey must have been recorded").isNotNull();
given()
.contentType(JSON)
.header(ORG_API_KEY_HEADER, oldApiKey)
.when()
.post("/organizations/" + orgId + "/verify")
.then()
.statusCode(403);
}
@Then("the response message confirms TAN was sent")
public void responseMessageConfirmsTan() {
String msg = lastResponse.jsonPath().getString("message");
assertThat(msg).as("TAN confirmation message").isNotBlank();
}
// ── When / Then: audit log ────────────────────────────────────────────────
@When("the owner requests the audit log")
public void ownerRequestsAuditLog() {
lastResponse = given()
.header(ORG_API_KEY_HEADER, apiKey)
.when()
.get("/organizations/" + orgId + "/audit-log")
.andReturn();
}
@When("the owner requests the audit log with the new api key")
public void ownerRequestsAuditLogWithNewKey() {
lastResponse = given()
.header(ORG_API_KEY_HEADER, apiKey)
.when()
.get("/organizations/" + orgId + "/audit-log")
.andReturn();
}
@When("a BSF admin requests the audit log")
public void adminRequestsAuditLog() {
lastResponse = given()
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
.when()
.get("/organizations/" + orgId + "/audit-log")
.andReturn();
}
@When("an unauthenticated caller requests the audit log")
public void unauthenticatedRequestsAuditLog() {
lastResponse = given()
.when()
.get("/organizations/" + orgId + "/audit-log")
.andReturn();
}
@Then("the audit log contains at least {int} event(s)")
public void auditLogContainsAtLeast(int minCount) {
List<?> events = lastResponse.jsonPath().getList("$");
assertThat(events).as("audit log event count").hasSizeGreaterThanOrEqualTo(minCount);
}
@Then("the audit log contains a {string} event triggered by {string}")
public void auditLogContainsEventByTrigger(String eventType, String triggeredBy) {
List<Map<String, Object>> events = lastResponse.jsonPath().getList("$");
boolean found = events.stream().anyMatch(e ->
eventType.equals(e.get("eventType")) && triggeredBy.equals(e.get("triggeredBy")));
assertThat(found)
.as("audit log must contain a %s event triggered by %s", eventType, triggeredBy)
.isTrue();
}
@Then("the audit log contains a {string} event")
public void auditLogContainsEvent(String eventType) {
List<Map<String, Object>> events = lastResponse.jsonPath().getList("$");
boolean found = events.stream().anyMatch(e -> eventType.equals(e.get("eventType")));
assertThat(found)
.as("audit log must contain a %s event", eventType)
.isTrue();
}
@Then("the first audit event is {string}")
public void firstAuditEventIs(String eventType) {
List<Map<String, Object>> events = lastResponse.jsonPath().getList("$");
assertThat(events).as("audit log must not be empty").isNotEmpty();
assertThat(events.get(0).get("eventType"))
.as("first event type").isEqualTo(eventType);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private void registerOrg(String name, String email, String jurisdiction,
String orgType, String domain, String targetLevel) {
Response r = given()
.contentType(JSON)
.body(buildRegistrationBody(name, email, jurisdiction, orgType, domain, targetLevel))
.when()
.post("/organizations")
.andReturn();
assertThat(r.statusCode()).as("registration must succeed").isEqualTo(201);
saveRegistrationState(r);
}
private void saveRegistrationState(Response r) {
orgId = UUID.fromString(r.jsonPath().getString("id"));
apiKey = r.jsonPath().getString("apiKey");
rotationSecret = r.jsonPath().getString("rotationSecret");
dnsToken = r.jsonPath().getString("dnsVerificationToken"); // may be null for UNVERIFIED
}
private void updateStateFromOrgResponse(Response r) {
String token = r.jsonPath().getString("dnsVerificationToken");
if (token != null) dnsToken = token;
}
private Map<String, Object> buildRegistrationBody(String name, String email,
String jurisdiction, String orgType,
String domain, String targetLevel) {
Map<String, Object> body = new HashMap<>();
if (name != null) body.put("registrantName", name);
if (email != null) body.put("registrantEmail", email);
if (jurisdiction != null) body.put("registrantJurisdiction", jurisdiction);
if (orgType != null) body.put("registrantOrgType", orgType);
if (domain != null) body.put("domain", domain);
if (targetLevel != null) body.put("targetOLevel", targetLevel);
return body;
}
private Instant clockNow() {
return Arc.container().instance(ClockService.class).get().now();
}
private void doAdminGrantTemp(String level, Instant expiresAt) {
lastResponse = given()
.contentType(JSON)
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
.body(Map.of(
"tempMaxOLevel", level,
"expiresAt", expiresAt.toString(),
"grantedBy", "test-admin",
"reason", "BDD test grant"))
.when()
.post("/organizations/" + orgId + "/temp-grant")
.andReturn();
}
// ── WireMock stub helpers ─────────────────────────────────────────────────
private void stubDohTxt(String name, String data) {
String escapedData = data.replace("\"", "\\\"");
WireMockSetup.wireMock.stubFor(WireMock.get(urlPathEqualTo("/dns-query"))
.withQueryParam("name", equalTo(name))
.withQueryParam("type", equalTo("TXT"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{\"Status\":0,\"Answer\":[{\"data\":\"\\\"" + escapedData + "\\\"\"}]}")));
}
private void stubDohTxtEmpty(String name) {
WireMockSetup.wireMock.stubFor(WireMock.get(urlPathEqualTo("/dns-query"))
.withQueryParam("name", equalTo(name))
.withQueryParam("type", equalTo("TXT"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{\"Status\":0,\"Answer\":[]}")));
}
private void stubDohMx(String domain, boolean hasRecords) {
String body = hasRecords
? "{\"Status\":0,\"Answer\":[{\"data\":\"10 mail." + domain + "\"}]}"
: "{\"Status\":0,\"Answer\":[]}";
WireMockSetup.wireMock.stubFor(WireMock.get(urlPathEqualTo("/dns-query"))
.withQueryParam("name", equalTo(domain))
.withQueryParam("type", equalTo("MX"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody(body)));
}
private void stubGleifFound(String lei) {
WireMockSetup.wireMock.stubFor(WireMock.get(urlPathEqualTo("/gleif/api/v1/lei-records"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{\"data\":[{\"id\":\"" + lei + "\"}]}")));
}
private void stubGleifEmpty() {
WireMockSetup.wireMock.stubFor(WireMock.get(urlPathEqualTo("/gleif/api/v1/lei-records"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{\"data\":[]}")));
}
private void stubOcFound() {
WireMockSetup.wireMock.stubFor(WireMock.get(urlPathEqualTo("/opencorporates/v0.4/companies/search"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{\"results\":{\"companies\":[{\"company\":{\"name\":\"Test\"}}]}}")));
}
private void stubOcEmpty() {
WireMockSetup.wireMock.stubFor(WireMock.get(urlPathEqualTo("/opencorporates/v0.4/companies/search"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{\"results\":{\"companies\":[]}}")));
}
private void stubSecurityTxt(String domain, int status) {
WireMockSetup.wireMock.stubFor(WireMock.get(urlEqualTo("/security-txt/" + domain))
.willReturn(aResponse().withStatus(status)));
}
}
@@ -21,7 +21,7 @@ public class TestSetup {
try (var conn = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/apix", "apix", "apix");
var stmt = conn.createStatement()) {
stmt.execute("TRUNCATE TABLE service_replacements, service_versions, services CASCADE");
stmt.execute("TRUNCATE TABLE service_replacements, service_versions, services, org_verification_events, organizations CASCADE");
}
}
@@ -0,0 +1,34 @@
package org.botstandards.apix.registry.bdd;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import io.cucumber.java.AfterAll;
import io.cucumber.java.BeforeAll;
/**
* Manages the WireMock server lifecycle across the entire BDD test run.
* All verifier HTTP clients (DoH, GLEIF, OC, security.txt) are pointed at
* localhost:9999 via test application.properties.
*
* Stubs are NOT configured here — each step definition registers its own stubs
* in Given steps and clears them in After hooks, keeping scenarios independent.
*/
public class WireMockSetup {
static WireMockServer wireMock;
@BeforeAll
public static void startWireMock() {
wireMock = new WireMockServer(WireMockConfiguration.options().port(9999));
wireMock.start();
}
@AfterAll
public static void stopWireMock() {
if (wireMock != null) wireMock.stop();
}
public static WireMockServer server() {
return wireMock;
}
}
@@ -12,3 +12,13 @@ quarkus.http.test-port=8181
# ── Test API key ──────────────────────────────────────────────────────────────
apix.api-key=test-api-key
# ── Verification (WireMock on port 9999) ─────────────────────────────────────
apix.dns.doh-url=http://localhost:9999/dns-query
apix.gleif.api-url=http://localhost:9999/gleif/api/v1
apix.opencorporates.api-url=http://localhost:9999/opencorporates/v0.4
apix.hygiene.security-txt-url-template=http://localhost:9999/security-txt/{domain}
apix.verification.http-timeout-ms=3000
# ── TAN exposure for BDD automation ──────────────────────────────────────────
apix.org.tan.expose-in-response=true
@@ -49,6 +49,44 @@ Feature: IoT device connection profile
And the iotProfile.hubUrl is "wss://new.openhub.io"
And the iotProfile.protocols contains "MQTT_5_0"
Scenario: protocol filter narrows replacement candidates
Given "OpenHub" has IoT profile hubUrl "wss://hub.openhub.io" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
And "OpenHub" has declared compatibility with "SmartHub Cloud"
And "CloudBridge" has IoT profile hubUrl "wss://hub.cloudbridge.io" protocols "WEBSOCKET" deviceClasses "device.class.smart-home-hub"
And "CloudBridge" has declared compatibility with "SmartHub Cloud"
When GET /services/{smartHubCloudId}/replacements?protocol=MQTT_5_0 is called with no authentication header
Then the response is HTTP 200
And the response contains 1 candidate
And the candidate is "OpenHub"
Scenario: Full IoT profile round-trip persists all declared fields
When "OpenHub" sets its full IoT profile
Then the response is HTTP 200
And GET /services/{openHubId} includes an iotProfile
And the iotProfile field "hubUrl" is "wss://hub.openhub.io/v2"
And the iotProfile field "tlsMinVersion" is "1.3"
And the iotProfile field "provisioningEndpoint" is "https://onboard.openhub.io/v1/provision"
And the iotProfile field "credentialFormat" is "JWT"
And the iotProfile field "deviceIdField" is "serialNumber"
And the iotProfile field "minFirmwareVersion" is "2.0.0"
And the iotProfile field "firmwareUpdateUrl" is "https://firmware.openhub.io/update"
And the iotProfile.estimatedMigrationMinutes is 5
Scenario: Device reads IoT connection profile anonymously without leaving a trace
Given "OpenHub" has IoT profile hubUrl "wss://hub.openhub.io/v2" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
And "OpenHub" has declared compatibility with "SmartHub Cloud"
When GET /services/{smartHubCloudId}/replacements is called with no authentication header
Then the response is HTTP 200
And the candidate "OpenHub" has an iotProfile with hubUrl "wss://hub.openhub.io/v2"
And the response headers contain no Set-Cookie
And the response headers contain a Server-Timing header
Scenario: Server-Timing header is present on replacement search responses
Given "OpenHub" has declared compatibility with "SmartHub Cloud"
When GET /services/{smartHubCloudId}/replacements is called with no authentication header
Then the response is HTTP 200
And the response headers contain a Server-Timing header
Scenario: Missing required fields on first IoT profile PATCH returns 422
When "OpenHub" patches iotProfile with only hubUrl "wss://hub.openhub.io"
Then the response is HTTP 422
@@ -0,0 +1,87 @@
Feature: Organisation audit log
# Immutable, append-only trail of all events affecting an organisation.
# Two authorised views: org owner (transparency) and BSF admin (yearly reporting).
# Unauthenticated callers are rejected.
Background:
Given the organisation registry is empty
# ── Access control ────────────────────────────────────────────────────────────
Scenario: Org owner can read their own audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner requests the audit log
Then the response status is 200
And the audit log contains at least 1 event
Scenario: BSF admin can read any org's audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When a BSF admin requests the audit log
Then the response status is 200
And the audit log contains at least 1 event
Scenario: Unauthenticated caller cannot read the audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When an unauthenticated caller requests the audit log
Then the response status is 403
# ── Registration events ────────────────────────────────────────────────────────
Scenario: Audit log records registration
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner requests the audit log
Then the audit log contains a "REGISTERED" event triggered by "SYSTEM"
# ── Key rotation events — TAN path ────────────────────────────────────────────
Scenario: TAN issuance is recorded in audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner has initiated key rotation using the rotation secret
When the owner requests the audit log
Then the audit log contains a "TAN_ISSUED" event triggered by "SYSTEM"
Scenario: TAN-based key rotation completion is recorded in audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner has initiated key rotation using the rotation secret
When the owner confirms key rotation with the TAN
And the owner requests the audit log with the new api key
Then the audit log contains a "KEY_ROTATED" event triggered by "OWNER"
# ── Key rotation events — DNS path ────────────────────────────────────────────
Scenario: DNS challenge initiation is recorded in audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the agent has initiated DNS-challenge key rotation
When the owner requests the audit log
Then the audit log contains a "DNS_ROTATION_INITIATED" event triggered by "SYSTEM"
Scenario: DNS-challenge key rotation completion is recorded in audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the agent has initiated DNS-challenge key rotation
And the agent has published the rotation challenge to dns
When the agent confirms DNS-challenge key rotation
And the owner requests the audit log with the new api key
Then the audit log contains a "KEY_ROTATED" event triggered by "OWNER"
# ── Fraud lock events ─────────────────────────────────────────────────────────
Scenario: Fraud report is recorded in audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner reports key rotation fraud using the registered email
When the owner requests the audit log
Then the audit log contains a "FRAUD_REPORTED" event triggered by "OWNER"
Scenario: Fraud lock clearance is recorded in audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner reports key rotation fraud using the registered email
When a BSF admin clears the fraud lock
And a BSF admin requests the audit log
Then the audit log contains a "FRAUD_LOCK_CLEARED" event triggered by "BSF_ADMIN"
# ── Event ordering ─────────────────────────────────────────────────────────────
Scenario: Audit log returns events newest first
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner has initiated key rotation using the rotation secret
When the owner requests the audit log
Then the first audit event is "TAN_ISSUED"
@@ -0,0 +1,153 @@
Feature: BSF admin actions — temp grants, revocation, TAN-based key rotation
Background:
Given the organisation registry is empty
# ── Temporary O-level grants ──────────────────────────────────────────────────
Scenario: BSF admin grants a temporary elevated O-level
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
When a BSF admin grants a temporary level "OPERATIONALLY_VERIFIED" expiring in 30 days
Then the response status is 200
And the effective O-level is "OPERATIONALLY_VERIFIED"
And the earned O-level is "IDENTITY_VERIFIED"
Scenario: Effective O-level drops back to earned level after temp grant expires
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
And a BSF admin grants a temporary level "OPERATIONALLY_VERIFIED" expiring in 2 hours
When time advances by 3 hours
And the caller reads the organisation
Then the effective O-level is "IDENTITY_VERIFIED"
Scenario: BSF admin revokes an active temporary grant
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
And a BSF admin grants a temporary level "OPERATIONALLY_VERIFIED" expiring in 30 days
When a BSF admin revokes the temporary grant
Then the response status is 200
And the effective O-level is "IDENTITY_VERIFIED"
Scenario: Revoking a non-existent temporary grant returns 422
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
When a BSF admin revokes the temporary grant
Then the response status is 422
Scenario: Temp grant with expiry in the past is rejected
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
When a BSF admin grants a temporary level "OPERATIONALLY_VERIFIED" expiring in the past
Then the response status is 422
Scenario: Temp grant request with wrong admin key is rejected
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
When an unauthorised caller grants a temporary level "OPERATIONALLY_VERIFIED"
Then the response status is 403
# ── Admin: manual O-level assignment ─────────────────────────────────────────
Scenario: BSF admin assigns earned level manually (O-4 post review)
Given an organisation is registered with target level "OPERATIONALLY_VERIFIED" for domain "example.com"
When a BSF admin assigns earned level "OPERATIONALLY_VERIFIED"
Then the response status is 200
And the earned O-level is "OPERATIONALLY_VERIFIED"
And the verification status is "ACHIEVED"
# ── Self-service key rotation (2FA: rotation secret + email TAN) ────────────────
Scenario: Owner rotates keys — rotation secret triggers TAN, TAN completes rotation
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner initiates key rotation using the rotation secret
Then the response status is 200
And the response contains a key rotation warning with the org name
And a key rotation TAN has been sent
When the owner confirms key rotation with the TAN
Then the response status is 200
And a new api key with prefix "apix_org_" is returned
And a new rotation secret with prefix "apix_rot_" is returned
And the old api key is no longer valid
Scenario: Key rotation initiation with wrong rotation secret is rejected
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner initiates key rotation using an invalid rotation secret
Then the response status is 403
Scenario: Key rotation TAN expires before confirmation — rotation is rejected
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner has initiated key rotation using the rotation secret
When time advances by 6 minutes
And the owner confirms key rotation with the TAN
Then the response status is 422
# ── Fraud report ─────────────────────────────────────────────────────────────
Scenario: Legitimate owner reports a key rotation they did not initiate
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner reports key rotation fraud using the registered email
Then the response status is 200
And the response confirms the fraud notification was received
Scenario: Fraud report with unrecognised email returns generic confirmation
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When a caller reports key rotation fraud using "unknown@other.com"
Then the response status is 200
And the response confirms the fraud notification was received
Scenario: Fraud report cancels the pending TAN and locks the org against further rotation
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner has initiated key rotation using the rotation secret
When the owner reports key rotation fraud using the registered email
Then the response status is 200
And the response confirms the fraud notification was received
When the owner confirms key rotation with the TAN
Then the response status is 422
Scenario: After fraud lock, rotation secret cannot initiate a new rotation
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner reports key rotation fraud using the registered email
When the owner initiates key rotation using the rotation secret
Then the response status is 422
Scenario: BSF admin clears fraud lock after investigation
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner reports key rotation fraud using the registered email
When a BSF admin clears the fraud lock
Then the response status is 200
And the org is no longer fraud-locked
When the owner initiates key rotation using the rotation secret
Then the response status is 200
# ── Emergency TAN-based key rotation ─────────────────────────────────────────
Scenario: Owner requests a TAN and uses it to rotate keys
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner requests a TAN using the registered email
Then the response status is 200
And the response message confirms TAN was sent
When the owner uses the TAN to rotate keys
Then the response status is 200
And a new api key with prefix "apix_org_" is returned
And a new rotation secret with prefix "apix_rot_" is returned
Scenario: TAN request with unrecognised email returns generic message
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner requests a TAN using "unknown@other.com"
Then the response status is 200
And the response message confirms TAN was sent
Scenario: TAN is rejected after its 5-minute validity window
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner has requested a TAN using the registered email
When time advances by 6 minutes
And the owner uses the TAN to rotate keys
Then the response status is 422
Scenario: TAN request rate limit — more than 3 requests within 24 hours is rejected
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner has requested a TAN 3 times within the last 24 hours
When the owner requests a TAN using the registered email
Then the response status is 422
Scenario: TAN request counter resets after 24 hours
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner has requested a TAN 3 times within the last 24 hours
When time advances by 25 hours
And the owner requests a TAN using the registered email
Then the response status is 200
@@ -0,0 +1,93 @@
Feature: DNS-challenge key rotation — bot-friendly ACME DNS-01 pattern
# Primary automated rotation path for agents with DNS API access.
# Two factors: rotation secret (proves legitimate requester) + DNS TXT control
# (proves domain ownership). No email parsing, no human inbox dependency.
#
# DNS record to publish: _apix-rotation.{domain} TXT "apix-rotate={challengeToken}"
# Window: 15 minutes (accommodates DNS API propagation + polling cycle)
# The challenge token is intentionally public it has no value without the rotation secret.
Background:
Given the organisation registry is empty
# ── Happy path ──────────────────────────────────────────────────────────────
Scenario: Agent initiates DNS-challenge rotation and confirms after publishing DNS record
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the agent initiates DNS-challenge key rotation
Then the response status is 200
And the response contains the dns record name "_apix-rotation.example.com"
And the response contains the challenge token and dns value format
And the response contains an expiry timestamp
Given the agent has published the rotation challenge to dns
When the agent confirms DNS-challenge key rotation
Then the response status is 200
And a new api key with prefix "apix_org_" is returned
And a new rotation secret with prefix "apix_rot_" is returned
And the old api key is no longer valid
# ── Failure paths ────────────────────────────────────────────────────────────
Scenario: Confirm fails when DNS record has not been published
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the agent has initiated DNS-challenge key rotation
And the DNS rotation challenge record is absent
When the agent confirms DNS-challenge key rotation
Then the response status is 422
Scenario: Confirm fails when challenge token does not match DNS record
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the agent has initiated DNS-challenge key rotation
And the DNS record "_apix-rotation.example.com" contains the wrong challenge token
When the agent confirms DNS-challenge key rotation
Then the response status is 422
Scenario: Confirm fails when the 15-minute challenge window has expired
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the agent has initiated DNS-challenge key rotation
And the agent has published the rotation challenge to dns
When time advances by 16 minutes
And the agent confirms DNS-challenge key rotation
Then the response status is 422
Scenario: Initiate fails with wrong rotation secret
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the agent initiates DNS-challenge key rotation with an invalid rotation secret
Then the response status is 403
Scenario: Confirm fails with wrong rotation secret
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the agent has initiated DNS-challenge key rotation
And the agent has published the rotation challenge to dns
When the agent confirms DNS-challenge key rotation with an invalid rotation secret
Then the response status is 403
Scenario: Confirm without a prior initiate is rejected
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the agent confirms DNS-challenge key rotation
Then the response status is 422
# ── Fraud lock interaction ────────────────────────────────────────────────────
Scenario: Fraud-locked org cannot initiate DNS-challenge rotation
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner reports key rotation fraud using the registered email
When the agent initiates DNS-challenge key rotation
Then the response status is 422
Scenario: Fraud-locked org cannot confirm DNS-challenge rotation
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the agent has initiated DNS-challenge key rotation
And the agent has published the rotation challenge to dns
And the owner reports key rotation fraud using the registered email
When the agent confirms DNS-challenge key rotation
Then the response status is 422
# ── Isolation between paths ───────────────────────────────────────────────────
Scenario: DNS challenge and email TAN are independent — each has its own pending state
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner has requested a TAN using the registered email
When the agent initiates DNS-challenge key rotation
Then the response status is 200
And the response contains the dns record name "_apix-rotation.example.com"
@@ -0,0 +1,50 @@
Feature: Organisation O-level transitions — owner-initiated upgrades and re-verification
Background:
Given the organisation registry is empty
Scenario: Owner upgrades target level from O-0 to O-1 and triggers verification successfully
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the DoH MX record "example.com" returns at least one entry
When the owner requests an upgrade to target level "IDENTITY_VERIFIED"
Then the response status is 200
And the verification status is "PENDING"
And a dns verification token is returned
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
When the owner triggers verification
Then the response status is 200
And the earned O-level is "IDENTITY_VERIFIED"
And the verification status is "ACHIEVED"
Scenario: Owner cannot downgrade target level below earned level
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
When the owner requests an upgrade to target level "UNVERIFIED"
Then the response status is 422
Scenario: Owner cannot set target level equal to earned level
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
When the owner requests an upgrade to target level "IDENTITY_VERIFIED"
Then the response status is 422
Scenario: Re-verification after previous failure resets the failure state
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
And the DoH TXT record "_apix-verification.example.com" returns no records
And the DoH MX record "example.com" returns at least one entry
When the owner triggers verification
Then the verification status is "FAILED"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
When the owner triggers verification
Then the response status is 200
And the earned O-level is "IDENTITY_VERIFIED"
And the verification status is "ACHIEVED"
Scenario: Upgrade to O-4 sets status to MANUAL_REVIEW
Given an organisation has earned O-level "HYGIENE_VERIFIED" with target "HYGIENE_VERIFIED"
When the owner requests an upgrade to target level "OPERATIONALLY_VERIFIED"
Then the response status is 200
And the verification status is "MANUAL_REVIEW"
Scenario: Requesting upgrade with wrong API key is rejected
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner requests an upgrade to target level "IDENTITY_VERIFIED" with an invalid api key
Then the response status is 403
@@ -0,0 +1,63 @@
Feature: Organisation registration
Background:
Given the organisation registry is empty
Scenario: Register with target O-0 (unverified) is immediately achieved
When a service registers with target level "UNVERIFIED"
Then the response status is 201
And the response contains an organisation id
And the response contains an api key with prefix "apix_org_"
And the response contains a rotation secret with prefix "apix_rot_"
And the earned O-level is "UNVERIFIED"
And the verification status is "ACHIEVED"
And no dns verification token is returned
Scenario: Register with target O-1 (identity verified) returns pending with DNS token
When a service registers with target level "IDENTITY_VERIFIED"
Then the response status is 201
And the earned O-level is "UNVERIFIED"
And the verification status is "PENDING"
And a dns verification token is returned
Scenario: Register with target O-2 (legal entity verified) returns pending with DNS token
When a service registers with target level "LEGAL_ENTITY_VERIFIED"
Then the response status is 201
And the earned O-level is "UNVERIFIED"
And the verification status is "PENDING"
And a dns verification token is returned
Scenario: Register with target O-3 (hygiene verified) returns pending with DNS token
When a service registers with target level "HYGIENE_VERIFIED"
Then the response status is 201
And the earned O-level is "UNVERIFIED"
And the verification status is "PENDING"
And a dns verification token is returned
Scenario: Register with target O-4 (operationally verified) goes to manual review
When a service registers with target level "OPERATIONALLY_VERIFIED"
Then the response status is 201
And the earned O-level is "UNVERIFIED"
And the verification status is "MANUAL_REVIEW"
Scenario: Register with target O-5 (audited) goes to manual review
When a service registers with target level "AUDITED"
Then the response status is 201
And the earned O-level is "UNVERIFIED"
And the verification status is "MANUAL_REVIEW"
Scenario: Registration fails when registrant name is missing
When a service registers without a registrant name
Then the response status is 400
Scenario: Registration fails when email is invalid
When a service registers with an invalid email
Then the response status is 400
Scenario: Registration fails when domain is missing
When a service registers without a domain
Then the response status is 400
Scenario: Registration fails when target O-level is missing
When a service registers without a target O-level
Then the response status is 400
@@ -0,0 +1,57 @@
Feature: Organisation verification — O-1 Identity Verified (DNS)
Background:
Given the organisation registry is empty
Scenario: Verification succeeds when DNS TXT and MX records are present
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
When the owner triggers verification
Then the response status is 200
And the earned O-level is "IDENTITY_VERIFIED"
And the verification status is "ACHIEVED"
Scenario: Verification fails when DNS TXT record is absent
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
And the DoH TXT record "_apix-verification.example.com" returns no records
And the DoH MX record "example.com" returns at least one entry
When the owner triggers verification
Then the response status is 200
And the earned O-level is "UNVERIFIED"
And the verification status is "FAILED"
And the verification step is "DNS_TXT"
Scenario: Verification fails when DNS TXT token does not match
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
And the DoH TXT record "_apix-verification.example.com" returns "wrong-token"
And the DoH MX record "example.com" returns at least one entry
When the owner triggers verification
Then the response status is 200
And the earned O-level is "UNVERIFIED"
And the verification status is "FAILED"
And the verification step is "DNS_TXT"
Scenario: Verification fails when MX record is absent
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns no records
When the owner triggers verification
Then the response status is 200
And the earned O-level is "UNVERIFIED"
And the verification status is "FAILED"
And the verification step is "DNS_MX"
Scenario: Verify is idempotent when status is already ACHIEVED
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
When the owner triggers verification
And the owner triggers verification again
Then the response status is 200
And the verification status is "ACHIEVED"
Scenario: Verification with wrong API key is rejected
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
When the owner triggers verification with an invalid api key
Then the response status is 403
@@ -0,0 +1,48 @@
Feature: Organisation verification — O-2 Legal Entity Verified (GLEIF / OpenCorporates)
Background:
Given the organisation registry is empty
Scenario: Verification succeeds via GLEIF when LEI is found for registrant name
Given an organisation is registered with target level "LEGAL_ENTITY_VERIFIED" for domain "example.com" with name "Acme Corp"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns an LEI "529900HNOAA1KXQJUQ27" for name "Acme Corp"
When the owner triggers verification
Then the response status is 200
And the earned O-level is "LEGAL_ENTITY_VERIFIED"
And the verification status is "ACHIEVED"
And the detected LEI is "529900HNOAA1KXQJUQ27"
Scenario: Verification falls back to OpenCorporates when GLEIF returns no results
Given an organisation is registered with target level "LEGAL_ENTITY_VERIFIED" for domain "example.com" with name "Local Gmbh"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns no results for name "Local Gmbh"
And the OpenCorporates API returns a company match for name "Local Gmbh"
When the owner triggers verification
Then the response status is 200
And the earned O-level is "LEGAL_ENTITY_VERIFIED"
And the verification status is "ACHIEVED"
Scenario: Verification fails when neither GLEIF nor OpenCorporates finds the company
Given an organisation is registered with target level "LEGAL_ENTITY_VERIFIED" for domain "example.com" with name "Ghost Inc"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns no results for name "Ghost Inc"
And the OpenCorporates API returns no results for name "Ghost Inc"
When the owner triggers verification
Then the response status is 200
And the earned O-level is "IDENTITY_VERIFIED"
And the verification status is "FAILED"
And the verification step is "OPENCORPORATES"
Scenario: Verification fails at O-1 DNS step and does not reach GLEIF
Given an organisation is registered with target level "LEGAL_ENTITY_VERIFIED" for domain "example.com" with name "Acme Corp"
And the DoH TXT record "_apix-verification.example.com" returns no records
And the DoH MX record "example.com" returns at least one entry
When the owner triggers verification
Then the response status is 200
And the earned O-level is "UNVERIFIED"
And the verification status is "FAILED"
And the verification step is "DNS_TXT"
@@ -0,0 +1,59 @@
Feature: Organisation verification — O-3 Hygiene Verified (security.txt, DMARC, SPF)
Background:
Given the organisation registry is empty
Scenario: Hygiene verification succeeds when all three hygiene checks pass
Given an organisation is registered with target level "HYGIENE_VERIFIED" for domain "example.com" with name "Acme Corp"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns an LEI "529900HNOAA1KXQJUQ27" for name "Acme Corp"
And the security.txt endpoint for "example.com" returns HTTP 200
And the DoH TXT record "_dmarc.example.com" returns a valid DMARC policy
And the DoH TXT record "example.com" returns a valid SPF record
When the owner triggers verification
Then the response status is 200
And the earned O-level is "HYGIENE_VERIFIED"
And the verification status is "ACHIEVED"
Scenario: Hygiene verification fails when security.txt is missing
Given an organisation is registered with target level "HYGIENE_VERIFIED" for domain "example.com" with name "Acme Corp"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns an LEI "529900HNOAA1KXQJUQ27" for name "Acme Corp"
And the security.txt endpoint for "example.com" returns HTTP 404
And the DoH TXT record "_dmarc.example.com" returns a valid DMARC policy
And the DoH TXT record "example.com" returns a valid SPF record
When the owner triggers verification
Then the response status is 200
And the earned O-level is "LEGAL_ENTITY_VERIFIED"
And the verification status is "FAILED"
And the verification step is "SECURITY_TXT"
Scenario: Hygiene verification fails when DMARC record is absent
Given an organisation is registered with target level "HYGIENE_VERIFIED" for domain "example.com" with name "Acme Corp"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns an LEI "529900HNOAA1KXQJUQ27" for name "Acme Corp"
And the security.txt endpoint for "example.com" returns HTTP 200
And the DoH TXT record "_dmarc.example.com" returns no records
And the DoH TXT record "example.com" returns a valid SPF record
When the owner triggers verification
Then the response status is 200
And the earned O-level is "LEGAL_ENTITY_VERIFIED"
And the verification status is "FAILED"
And the verification step is "DMARC"
Scenario: Hygiene verification fails when SPF record is absent
Given an organisation is registered with target level "HYGIENE_VERIFIED" for domain "example.com" with name "Acme Corp"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns an LEI "529900HNOAA1KXQJUQ27" for name "Acme Corp"
And the security.txt endpoint for "example.com" returns HTTP 200
And the DoH TXT record "_dmarc.example.com" returns a valid DMARC policy
And the DoH TXT record "example.com" returns no records
When the owner triggers verification
Then the response status is 200
And the earned O-level is "LEGAL_ENTITY_VERIFIED"
And the verification status is "FAILED"
And the verification step is "SPF"
@@ -1,8 +1,8 @@
# Prevent the Cucumber engine from discovering feature files on its own.
# Features are provided only by IotTransitionCucumberTest via @SelectClasspathResource,
# Features are provided only by IotTransitionCucumberIT via @SelectClasspathResource,
# which ensures Cucumber runs within the @QuarkusTest context (Quarkus server started).
# Prevent standalone Cucumber execution. No feature file carries this tag, so the
# Cucumber engine discovers scenarios but filters them to zero when running on its own.
# Cucumber is run explicitly by IotTransitionCucumberTest via Main.run(), which does
# Cucumber is run explicitly by IotTransitionCucumberIT via Main.run(), which does
# not apply this JUnit Platform filter.
cucumber.filter.tags=@_disabled_standalone_run_
@@ -0,0 +1,26 @@
package org.botstandards.apix.registry.dto;
import java.util.List;
public record MailSigningKeyResponse(
String issuer,
SigningInfo signingInfo,
List<KeyInfo> keys) {
public record SigningInfo(
String algorithm,
String curve,
String javaAlgorithmId,
String canonicalization,
String signatureEncoding,
List<String> verificationSteps,
String javaKeyReconstruction) {}
public record KeyInfo(
String kid,
String kty,
String crv,
String x,
String use,
String publicKeySpkiBase64) {}
}
@@ -0,0 +1,37 @@
package org.botstandards.apix.registry.dto;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.OrgEventType;
import org.botstandards.apix.registry.entity.OrgVerificationEventEntity;
import org.botstandards.apix.registry.service.MailSigningService;
import java.time.Instant;
import java.util.UUID;
public record OrgAuditEventResponse(
UUID id,
OrgEventType eventType,
OLevel fromLevel,
OLevel toLevel,
String triggeredBy,
String notes,
Instant createdAt,
String kid,
String signature) {
public static OrgAuditEventResponse from(OrgVerificationEventEntity ev, UUID orgId,
MailSigningService signer) {
String sig = signer.signAuditEvent(
ev.id, orgId,
ev.eventType.name(),
ev.fromLevel != null ? ev.fromLevel.name() : null,
ev.toLevel != null ? ev.toLevel.name() : null,
ev.triggeredBy,
ev.notes,
ev.createdAt.toString());
return new OrgAuditEventResponse(
ev.id, ev.eventType, ev.fromLevel, ev.toLevel,
ev.triggeredBy, ev.notes, ev.createdAt,
signer.getKid(), sig);
}
}
@@ -0,0 +1,11 @@
package org.botstandards.apix.registry.dto;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record OrgEventFeedResponse(
UUID orgId,
Instant generatedAt,
String kid,
List<OrgAuditEventResponse> events) {}
@@ -0,0 +1,11 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrgFraudReportRequest(
@NotBlank @Email String email,
String notes
) {}
@@ -0,0 +1,13 @@
package org.botstandards.apix.registry.dto;
import java.util.Map;
public record OrgKeyRotateResponse(
String apiKey,
String rotationSecret,
String warning,
Map<String, Object> signedNotification) {
public static final String KEY_WARNING =
"New credentials shown exactly once. Store securely — they cannot be retrieved again.";
}
@@ -0,0 +1,17 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.botstandards.apix.common.OLevel;
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrgRegistrationRequest(
@NotBlank String registrantName,
@NotBlank @Email String registrantEmail,
@NotBlank String registrantJurisdiction,
@NotBlank String registrantOrgType,
@NotBlank String domain,
@NotNull OLevel targetOLevel
) {}
@@ -0,0 +1,25 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.VerificationStatus;
import java.util.UUID;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record OrgRegistrationResponse(
UUID id,
String apiKey,
String rotationSecret,
OLevel targetOLevel,
OLevel earnedOLevel,
VerificationStatus verificationStatus,
// Only present for targetOLevel >= IDENTITY_VERIFIED — org must publish this as DNS TXT record
String dnsVerificationToken,
String warning
) {
public static final String KEY_WARNING =
"Both credentials are shown exactly once. " +
"The rotationSecret is your emergency key — treat it as more sensitive than the apiKey. " +
"Exposure of the rotationSecret bypasses self-service recovery.";
}
@@ -0,0 +1,46 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.VerificationStatus;
import org.botstandards.apix.registry.entity.OrganizationEntity;
import java.time.Instant;
import java.util.UUID;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record OrgResponse(
UUID id,
String registrantName,
String registrantEmail,
String registrantJurisdiction,
String registrantOrgType,
String domain,
String lei,
OLevel targetOLevel,
OLevel earnedOLevel,
OLevel effectiveOLevel,
VerificationStatus verificationStatus,
String verificationStep,
String verificationError,
String dnsVerificationToken,
OLevel tempMaxOLevel,
Instant tempMaxExpiresAt,
boolean fraudLocked,
Instant fraudLockedAt,
Instant createdAt,
Instant updatedAt
) {
public static OrgResponse from(OrganizationEntity e, Instant now) {
return new OrgResponse(
e.id, e.registrantName, e.registrantEmail, e.registrantJurisdiction,
e.registrantOrgType, e.domain, e.lei,
e.targetOLevel, e.earnedOLevel, e.effectiveOLevel(now),
e.verificationStatus, e.verificationStep, e.verificationError,
e.dnsVerificationToken,
e.tempMaxOLevel, e.tempMaxExpiresAt,
e.fraudLocked, e.fraudLockedAt,
e.createdAt, e.updatedAt
);
}
}
@@ -0,0 +1,8 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrgTanRequestBody(@NotBlank @Email String email) {}
@@ -0,0 +1,7 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.validation.constraints.NotBlank;
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrgTanRotateRequest(@NotBlank String tan) {}
@@ -0,0 +1,16 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.botstandards.apix.common.OLevel;
import java.time.Instant;
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrgTempGrantRequest(
@NotNull OLevel tempMaxOLevel,
@NotNull Instant expiresAt,
@NotBlank String grantedBy,
String reason
) {}
@@ -0,0 +1,8 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.validation.constraints.NotNull;
import org.botstandards.apix.common.OLevel;
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrgUpgradeRequest(@NotNull OLevel targetOLevel) {}
@@ -0,0 +1,42 @@
package org.botstandards.apix.registry.entity;
import jakarta.persistence.*;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.OrgEventType;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "org_verification_events")
public class OrgVerificationEventEntity {
@Id
@Column(columnDefinition = "uuid")
public UUID id;
@Column(name = "organization_id", nullable = false, columnDefinition = "uuid")
public UUID organizationId;
@Enumerated(EnumType.STRING)
@Column(name = "event_type", nullable = false, length = 50)
public OrgEventType eventType;
@Enumerated(EnumType.STRING)
@Column(name = "from_level", length = 50)
public OLevel fromLevel;
@Enumerated(EnumType.STRING)
@Column(name = "to_level", length = 50)
public OLevel toLevel;
// SYSTEM | OWNER | BSF_ADMIN
@Column(name = "triggered_by", length = 50)
public String triggeredBy;
@Column(name = "notes", columnDefinition = "text")
public String notes;
@Column(name = "created_at", nullable = false)
public Instant createdAt;
}
@@ -0,0 +1,117 @@
package org.botstandards.apix.registry.entity;
import jakarta.persistence.*;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.VerificationStatus;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "organizations")
public class OrganizationEntity {
@Id
@Column(columnDefinition = "uuid")
public UUID id;
@Column(name = "registrant_name", nullable = false, length = 255)
public String registrantName;
@Column(name = "registrant_email", nullable = false, length = 255)
public String registrantEmail;
@Column(name = "registrant_jurisdiction", nullable = false, length = 10)
public String registrantJurisdiction;
@Column(name = "registrant_org_type", nullable = false, length = 50)
public String registrantOrgType;
@Column(name = "domain", nullable = false, length = 255)
public String domain;
@Column(name = "lei", length = 20)
public String lei;
@Column(name = "api_key_hash", nullable = false, length = 64, unique = true)
public String apiKeyHash;
@Column(name = "rotation_secret_hash", nullable = false, length = 64, unique = true)
public String rotationSecretHash;
@Enumerated(EnumType.STRING)
@Column(name = "target_o_level", nullable = false, length = 50)
public OLevel targetOLevel;
@Enumerated(EnumType.STRING)
@Column(name = "earned_o_level", nullable = false, length = 50)
public OLevel earnedOLevel = OLevel.UNVERIFIED;
@Enumerated(EnumType.STRING)
@Column(name = "verification_status", nullable = false, length = 50)
public VerificationStatus verificationStatus = VerificationStatus.PENDING;
@Column(name = "verification_step", length = 100)
public String verificationStep;
@Column(name = "verification_error", columnDefinition = "text")
public String verificationError;
@Column(name = "dns_verification_token", length = 64)
public String dnsVerificationToken;
// BSF temporary grant
@Enumerated(EnumType.STRING)
@Column(name = "temp_max_o_level", length = 50)
public OLevel tempMaxOLevel;
@Column(name = "temp_max_expires_at")
public Instant tempMaxExpiresAt;
@Column(name = "temp_max_granted_by", length = 255)
public String tempMaxGrantedBy;
@Column(name = "temp_max_granted_reason", columnDefinition = "text")
public String tempMaxGrantedReason;
// Emergency key rotation TAN
@Column(name = "pending_tan_hash", length = 64)
public String pendingTanHash;
@Column(name = "pending_tan_expires_at")
public Instant pendingTanExpires;
@Column(name = "tan_request_count_24h", nullable = false)
public int tanRequestCount24h = 0;
@Column(name = "tan_last_requested_at")
public Instant tanLastRequestedAt;
// DNS-challenge key rotation (bot-friendly, analogous to ACME DNS-01)
// Token is stored plaintext — it is a public DNS value, intentionally not secret.
@Column(name = "pending_rotation_challenge_token", length = 64)
public String pendingRotationChallengeToken;
@Column(name = "pending_rotation_challenge_expires_at")
public Instant pendingRotationChallengeExpires;
// Fraud lock: set by reportFraud(); blocks all key-rotation attempts until cleared by BSF admin.
@Column(name = "fraud_locked", nullable = false)
public boolean fraudLocked = false;
@Column(name = "fraud_locked_at")
public Instant fraudLockedAt;
@Column(name = "created_at", nullable = false)
public Instant createdAt;
@Column(name = "updated_at", nullable = false)
public Instant updatedAt;
public OLevel effectiveOLevel(Instant now) {
if (tempMaxOLevel != null && tempMaxExpiresAt != null && now.isBefore(tempMaxExpiresAt)) {
return tempMaxOLevel.ordinal() > earnedOLevel.ordinal() ? tempMaxOLevel : earnedOLevel;
}
return earnedOLevel;
}
}
@@ -0,0 +1,29 @@
package org.botstandards.apix.registry.filter;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.container.ContainerResponseContext;
import jakarta.ws.rs.container.ContainerResponseFilter;
import jakarta.ws.rs.ext.Provider;
import java.io.IOException;
@Provider
public class ServerTimingFilter implements ContainerRequestFilter, ContainerResponseFilter {
private static final String START_NS = "apix.start.ns";
@Override
public void filter(ContainerRequestContext req) throws IOException {
req.setProperty(START_NS, System.nanoTime());
}
@Override
public void filter(ContainerRequestContext req, ContainerResponseContext resp) throws IOException {
Object start = req.getProperty(START_NS);
if (start instanceof Long startNs) {
double ms = (System.nanoTime() - startNs) / 1_000_000.0;
resp.getHeaders().add("Server-Timing", String.format("app;dur=%.1f", ms));
}
}
}
@@ -0,0 +1,63 @@
package org.botstandards.apix.registry.resource;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import org.botstandards.apix.registry.dto.MailSigningKeyResponse;
import org.botstandards.apix.registry.service.MailSigningService;
import java.util.List;
/**
* Public endpoint exposing the APIX mail signing public key and verification instructions.
*
* Recipients of APIX notification emails can fetch the public key identified by the 'kid'
* field in the email's signed payload and verify the Ed25519 signature to confirm the
* message is authentic and unchanged.
*
* No authentication required — this endpoint is intentionally public.
*/
@Path("/mail-signing-keys")
@Produces(MediaType.APPLICATION_JSON)
public class MailSigningKeyResource {
@Inject MailSigningService signingService;
@GET
public MailSigningKeyResponse getKeys() {
return new MailSigningKeyResponse(
"apix-registry",
new MailSigningKeyResponse.SigningInfo(
"EdDSA",
"Ed25519",
"Ed25519",
"Fields sorted alphabetically by key name, null-valued fields excluded, " +
"compact JSON (no whitespace), UTF-8 encoded. " +
"The 'signature' and 'kid' fields are excluded from the canonical form before signing.",
"Base64URL without padding (RFC 4648 §5)",
List.of(
"1. Parse the signed JSON payload from the email body (delimited by ---APIX-SIGNED-PAYLOAD---).",
"2. Extract the 'kid' value. Fetch the corresponding key from GET /mail-signing-keys.",
"3. Remove the 'signature' and 'kid' fields from the parsed object.",
"4. Remove any fields with null values.",
"5. Sort the remaining fields alphabetically by key name.",
"6. Serialize to compact JSON (no whitespace) encoded as UTF-8 bytes.",
"7. Decode the 'signature' string from Base64URL.",
"8. Verify the Ed25519 signature over the UTF-8 bytes using the public key."
),
"byte[] spki = Base64.getDecoder().decode(publicKeySpkiBase64); " +
"PublicKey pk = KeyFactory.getInstance(\"Ed25519\").generatePublic(new X509EncodedKeySpec(spki)); " +
"Signature sig = Signature.getInstance(\"Ed25519\"); " +
"sig.initVerify(pk); sig.update(canonicalBytes); boolean valid = sig.verify(signatureBytes);"
),
List.of(new MailSigningKeyResponse.KeyInfo(
signingService.getKid(),
"OKP",
"Ed25519",
signingService.getPublicKeyX(),
"sig",
signingService.getPublicKeySpki()
))
);
}
}
@@ -0,0 +1,197 @@
package org.botstandards.apix.registry.resource;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.registry.dto.*;
import org.botstandards.apix.registry.service.OrganizationService;
import java.net.URI;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Path("/organizations")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class OrganizationResource {
@Inject OrganizationService orgService;
// ── Public registration ───────────────────────────────────────────────────
@POST
public Response register(@Valid OrgRegistrationRequest req) {
OrgRegistrationResponse resp = orgService.register(req);
return Response.created(URI.create("/organizations/" + resp.id()))
.entity(resp)
.build();
}
// ── Read ──────────────────────────────────────────────────────────────────
@GET
@Path("/{id}")
public OrgResponse getById(@PathParam("id") UUID id) {
return orgService.getById(id);
}
// ── Audit log ─────────────────────────────────────────────────────────────
// Accessible to the org owner (X-Org-Api-Key) or BSF admin (X-Api-Key).
// Events are returned newest-first; suitable for BSF yearly reporting and
// owner-facing transparency view.
@GET
@Path("/{id}/audit-log")
public List<OrgAuditEventResponse> getAuditLog(
@PathParam("id") UUID id,
@HeaderParam("X-Org-Api-Key") String apiKey,
@HeaderParam("X-Api-Key") String adminKey) {
return orgService.getAuditLog(id, apiKey, adminKey);
}
// ── Event feed — signed, chronological, agent and BSF reporting friendly ──
// Oldest-first; optional ?since=<ISO8601> for incremental polling.
// Every event carries an Ed25519 signature verifiable against GET /mail-signing-keys.
@GET
@Path("/{id}/event-feed")
public OrgEventFeedResponse getEventFeed(
@PathParam("id") UUID id,
@HeaderParam("X-Org-Api-Key") String apiKey,
@HeaderParam("X-Api-Key") String adminKey,
@QueryParam("since") String sinceParam) {
Instant since = sinceParam != null && !sinceParam.isBlank()
? Instant.parse(sinceParam) : null;
return orgService.getEventFeed(id, apiKey, adminKey, since);
}
// ── Owner: trigger verification ───────────────────────────────────────────
@POST
@Path("/{id}/verify")
public OrgResponse verify(@PathParam("id") UUID id,
@HeaderParam("X-Org-Api-Key") String key) {
return orgService.verify(id, key);
}
// ── Owner: request upgrade to a higher level ──────────────────────────────
@POST
@Path("/{id}/request-upgrade")
public OrgResponse requestUpgrade(@PathParam("id") UUID id,
@Valid OrgUpgradeRequest req,
@HeaderParam("X-Org-Api-Key") String key) {
return orgService.requestUpgrade(id, req, key);
}
// ── Admin: assign earned level manually (O-4+) ────────────────────────────
@PATCH
@Path("/{id}/earned-level")
public OrgResponse assignEarnedLevel(@PathParam("id") UUID id,
Map<String, String> body,
@HeaderParam("X-Api-Key") String adminKey) {
String levelStr = body.get("earnedOLevel");
if (levelStr == null || levelStr.isBlank()) {
throw new BadRequestException("earnedOLevel is required");
}
OLevel level;
try {
level = OLevel.valueOf(levelStr);
} catch (IllegalArgumentException e) {
throw new BadRequestException("unknown OLevel: " + levelStr);
}
return orgService.assignEarnedLevel(id, level, adminKey);
}
// ── Admin: grant temporary elevated level ─────────────────────────────────
@POST
@Path("/{id}/temp-grant")
public OrgResponse grantTemp(@PathParam("id") UUID id,
@Valid OrgTempGrantRequest req,
@HeaderParam("X-Api-Key") String adminKey) {
return orgService.grantTemp(id, req, adminKey);
}
// ── Admin: revoke temporary grant ─────────────────────────────────────────
@DELETE
@Path("/{id}/temp-grant")
public OrgResponse revokeTemp(@PathParam("id") UUID id,
@HeaderParam("X-Api-Key") String adminKey) {
return orgService.revokeTemp(id, adminKey);
}
// ── Owner: self-service key rotation (step 1 — issues 2FA TAN to registered email) ──
@POST
@Path("/{id}/rotate-key")
public Response rotateKey(@PathParam("id") UUID id,
@HeaderParam("X-Org-Rotation-Secret") String rotationSecret) {
return Response.ok(orgService.rotateKey(id, rotationSecret)).build();
}
// ── Emergency: request TAN ───────────────────────────────────────────────
@POST
@Path("/{id}/request-tan")
public Response requestTan(@PathParam("id") UUID id,
@Valid OrgTanRequestBody body) {
return Response.ok(orgService.requestTan(id, body)).build();
}
// ── DNS-challenge key rotation (bot-friendly) ─────────────────────────────
// Step 1: rotation secret → challenge token (agent publishes to DNS)
// Step 2: rotation secret again → DoH verifies DNS, new keys issued
// No email or inbox access required — machine-native ACME DNS-01 pattern.
@POST
@Path("/{id}/rotate-key-dns")
public Response initiateKeyRotationDns(@PathParam("id") UUID id,
@HeaderParam("X-Org-Rotation-Secret") String rotationSecret) {
return Response.ok(orgService.initiateKeyRotationDns(id, rotationSecret)).build();
}
@POST
@Path("/{id}/confirm-key-rotation-dns")
public OrgKeyRotateResponse confirmKeyRotationDns(@PathParam("id") UUID id,
@HeaderParam("X-Org-Rotation-Secret") String rotationSecret) {
return orgService.confirmKeyRotationDns(id, rotationSecret);
}
// ── Key rotation step 2 / emergency: complete rotation using TAN ─────────
// Shared completion endpoint for both self-service 2FA (rotation secret → TAN)
// and emergency recovery (email → TAN). No API key required — TAN is the auth.
@POST
@Path("/{id}/rotate-key-with-tan")
public OrgKeyRotateResponse rotateKeyWithTan(@PathParam("id") UUID id,
@Valid OrgTanRotateRequest body) {
return orgService.rotateKeyWithTan(id, body);
}
// ── Admin: clear fraud lock after investigation ───────────────────────────
@DELETE
@Path("/{id}/fraud-lock")
public OrgResponse clearFraudLock(@PathParam("id") UUID id,
@HeaderParam("X-Api-Key") String adminKey) {
return orgService.clearFraudLock(id, adminKey);
}
// ── Fraud report — no auth required ──────────────────────────────────────
// Called by a legitimate owner who received an unsolicited key rotation warning.
// Always returns a generic confirmation to prevent org-existence enumeration.
@POST
@Path("/{id}/notify-key-rotation-fraud")
public Response notifyKeyRotationFraud(@PathParam("id") UUID id,
@Valid OrgFraudReportRequest req) {
return Response.ok(orgService.reportFraud(id, req)).build();
}
}
@@ -0,0 +1,159 @@
package org.botstandards.apix.registry.service;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.*;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Ed25519 signing service for all outbound APIX notifications and audit events.
*
* Canonical form for signing: fields sorted alphabetically by key, null-valued
* fields excluded, compact JSON (no whitespace), UTF-8 encoded. The 'signature'
* and 'kid' fields are always excluded from the payload before signing.
*
* In dev mode (no key configured) an ephemeral key pair is generated at startup.
* Set APIX_MAIL_SIGNING_PRIVATE_KEY and APIX_MAIL_SIGNING_PUBLIC_KEY (PKCS#8 /
* SubjectPublicKeyInfo, Base64 standard encoding) for production.
*/
@ApplicationScoped
public class MailSigningService {
private static final Logger log = Logger.getLogger(MailSigningService.class);
@ConfigProperty(name = "apix.mail.signing.private-key-base64")
Optional<String> privateKeyBase64;
@ConfigProperty(name = "apix.mail.signing.public-key-base64")
Optional<String> publicKeyBase64;
@ConfigProperty(name = "apix.mail.signing.kid", defaultValue = "dev")
String kid;
private PrivateKey privateKey;
private PublicKey publicKey;
@PostConstruct
void init() {
try {
if (privateKeyBase64.isEmpty() || publicKeyBase64.isEmpty()) {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519");
KeyPair kp = kpg.generateKeyPair();
this.privateKey = kp.getPrivate();
this.publicKey = kp.getPublic();
log.warn("Mail signing: ephemeral Ed25519 key pair (dev/test mode). " +
"Set APIX_MAIL_SIGNING_PRIVATE_KEY and APIX_MAIL_SIGNING_PUBLIC_KEY for production.");
} else {
KeyFactory kf = KeyFactory.getInstance("Ed25519");
this.privateKey = kf.generatePrivate(
new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyBase64.get())));
this.publicKey = kf.generatePublic(
new X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyBase64.get())));
}
} catch (Exception e) {
throw new IllegalStateException("Mail signing key initialisation failed", e);
}
}
public String getKid() {
return kid;
}
/**
* Returns the raw 32-byte Ed25519 public key as Base64URL (JWK OKP 'x' field, RFC 8037).
* The SubjectPublicKeyInfo encoding is always a fixed 12-byte header followed by the raw key.
*/
public String getPublicKeyX() {
byte[] spki = publicKey.getEncoded();
byte[] raw = Arrays.copyOfRange(spki, spki.length - 32, spki.length);
return Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
}
/**
* Returns the full SubjectPublicKeyInfo (SPKI) as Base64 standard encoding.
* Java consumers: KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(value)))
*/
public String getPublicKeySpki() {
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
}
/**
* Signs a flat string map. Null values are excluded from the canonical form.
* Returns the Base64URL-encoded Ed25519 signature.
*/
public String sign(Map<String, String> fields) {
String canonical = buildCanonical(fields);
try {
Signature sig = Signature.getInstance("Ed25519");
sig.initSign(privateKey);
sig.update(canonical.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(sig.sign());
} catch (Exception e) {
throw new IllegalStateException("Signing failed", e);
}
}
/** Builds the signed notification payload for a rotation or key lifecycle event. */
public Map<String, Object> signedNotification(UUID orgId, String eventType,
String timestamp, String message) {
Map<String, String> fields = new TreeMap<>();
fields.put("eventType", eventType);
fields.put("issuer", "apix-registry");
fields.put("message", message);
fields.put("orgId", orgId.toString());
fields.put("timestamp", timestamp);
String signature = sign(fields);
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("kid", kid);
payload.put("issuer", "apix-registry");
payload.put("orgId", orgId.toString());
payload.put("eventType", eventType);
payload.put("timestamp", timestamp);
payload.put("message", message);
payload.put("signature", signature);
return payload;
}
/** Builds the canonical JSON for an audit event (non-null fields only, sorted). */
public String signAuditEvent(UUID eventId, UUID orgId, String eventType,
String fromLevel, String toLevel, String triggeredBy,
String notes, String createdAt) {
Map<String, String> fields = new TreeMap<>();
fields.put("createdAt", createdAt);
fields.put("eventType", eventType);
if (fromLevel != null) fields.put("fromLevel", fromLevel);
fields.put("id", eventId.toString());
if (notes != null) fields.put("notes", notes);
fields.put("orgId", orgId.toString());
if (toLevel != null) fields.put("toLevel", toLevel);
fields.put("triggeredBy", triggeredBy);
return sign(fields);
}
// Canonical form: fields sorted alphabetically, null values excluded, compact JSON, UTF-8
static String buildCanonical(Map<String, String> fields) {
return fields.entrySet().stream()
.filter(e -> e.getValue() != null)
.sorted(Map.Entry.comparingByKey())
.map(e -> "\"" + jsonEscape(e.getKey()) + "\":\"" + jsonEscape(e.getValue()) + "\"")
.collect(Collectors.joining(",", "{", "}"));
}
private static String jsonEscape(String s) {
return s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
}
}
@@ -0,0 +1,649 @@
package org.botstandards.apix.registry.service;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import org.botstandards.apix.common.*;
import org.botstandards.apix.registry.dto.*;
import org.botstandards.apix.registry.entity.OrgVerificationEventEntity;
import org.botstandards.apix.registry.entity.OrganizationEntity;
import org.botstandards.apix.verification.RotationChallengeVerifier;
import org.botstandards.apix.verification.VerificationConfig;
import org.botstandards.apix.verification.VerificationPipeline;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class OrganizationService {
@Inject EntityManager em;
@Inject ClockService clock;
@Inject MailSigningService mailSigner;
@ConfigProperty(name = "apix.api-key") String adminApiKey;
@ConfigProperty(name = "apix.dns.doh-url") String dohUrl;
@ConfigProperty(name = "apix.gleif.api-url") String gleifApiUrl;
@ConfigProperty(name = "apix.opencorporates.api-key") Optional<String> ocApiKey;
@ConfigProperty(name = "apix.opencorporates.api-url", defaultValue = "https://api.opencorporates.com/v0.4") String ocApiUrl;
@ConfigProperty(name = "apix.hygiene.security-txt-url-template",
defaultValue = "https://{domain}/.well-known/security.txt") String securityTxtTemplate;
@ConfigProperty(name = "apix.verification.http-timeout-ms", defaultValue = "5000") long httpTimeoutMs;
@ConfigProperty(name = "apix.org.tan.expose-in-response", defaultValue = "false") boolean exposeTanInResponse;
// ── Public registration ───────────────────────────────────────────────────
@Transactional
public OrgRegistrationResponse register(OrgRegistrationRequest req) {
Instant now = clock.now();
String apiKey = generateKey("apix_org_");
String rotationSecret = generateKey("apix_rot_");
String dnsToken = req.targetOLevel().ordinal() >= OLevel.IDENTITY_VERIFIED.ordinal()
? generateToken() : null;
OrganizationEntity org = new OrganizationEntity();
org.id = UUID.randomUUID();
org.registrantName = req.registrantName();
org.registrantEmail = req.registrantEmail();
org.registrantJurisdiction = req.registrantJurisdiction();
org.registrantOrgType = req.registrantOrgType();
org.domain = req.domain();
org.apiKeyHash = hash(apiKey);
org.rotationSecretHash = hash(rotationSecret);
org.targetOLevel = req.targetOLevel();
org.earnedOLevel = OLevel.UNVERIFIED;
org.dnsVerificationToken = dnsToken;
org.createdAt = now;
org.updatedAt = now;
if (req.targetOLevel() == OLevel.UNVERIFIED) {
org.verificationStatus = VerificationStatus.ACHIEVED;
} else if (req.targetOLevel().ordinal() >= OLevel.OPERATIONALLY_VERIFIED.ordinal()) {
org.verificationStatus = VerificationStatus.MANUAL_REVIEW;
} else {
org.verificationStatus = VerificationStatus.PENDING;
}
em.persist(org);
recordEvent(org.id, OrgEventType.REGISTERED, null, null, "SYSTEM", null, now);
return new OrgRegistrationResponse(
org.id, apiKey, rotationSecret,
org.targetOLevel, org.earnedOLevel, org.verificationStatus,
dnsToken, OrgRegistrationResponse.KEY_WARNING);
}
// ── Read ──────────────────────────────────────────────────────────────────
public OrgResponse getById(UUID id) {
return OrgResponse.from(requireOrg(id), clock.now());
}
// ── Audit log — accessible to org owner (api key) or BSF admin ────────────
// Returns all events for the org, newest first.
// Either credential is sufficient; the caller does not learn which one was accepted.
public List<OrgAuditEventResponse> getAuditLog(UUID id, String apiKeyHeader, String adminKeyHeader) {
OrganizationEntity org = requireAuthorized(id, apiKeyHeader, adminKeyHeader);
return em.createQuery(
"SELECT e FROM OrgVerificationEventEntity e " +
"WHERE e.organizationId = :orgId ORDER BY e.createdAt DESC",
OrgVerificationEventEntity.class)
.setParameter("orgId", id)
.getResultList()
.stream()
.map(ev -> OrgAuditEventResponse.from(ev, id, mailSigner))
.toList();
}
// ── Event feed — signed, chronological, agent-friendly ───────────────────
// Oldest-first ordering suits sequential consumption by agents and BSF tooling.
// Optional 'since' filter enables incremental polling without re-fetching known events.
// Each event carries an Ed25519 signature allowing receivers to verify integrity
// and APIX authorship. The 'kid' in the response identifies the signing key.
public OrgEventFeedResponse getEventFeed(UUID id, String apiKeyHeader, String adminKeyHeader,
Instant since) {
requireAuthorized(id, apiKeyHeader, adminKeyHeader);
Instant now = clock.now();
String baseQuery = "SELECT e FROM OrgVerificationEventEntity e " +
"WHERE e.organizationId = :orgId";
if (since != null) baseQuery += " AND e.createdAt > :since";
baseQuery += " ORDER BY e.createdAt ASC";
var query = em.createQuery(baseQuery, OrgVerificationEventEntity.class)
.setParameter("orgId", id);
if (since != null) query.setParameter("since", since);
List<OrgAuditEventResponse> events = query.getResultList()
.stream()
.map(ev -> OrgAuditEventResponse.from(ev, id, mailSigner))
.toList();
return new OrgEventFeedResponse(id, now, mailSigner.getKid(), events);
}
private OrganizationEntity requireAuthorized(UUID id, String apiKeyHeader, String adminKeyHeader) {
OrganizationEntity org = requireOrg(id);
boolean isOwner = apiKeyHeader != null && hash(apiKeyHeader).equals(org.apiKeyHash);
boolean isAdmin = adminApiKey.equals(adminKeyHeader);
if (!isOwner && !isAdmin) {
throw new ForbiddenException("valid X-Org-Api-Key or X-Api-Key required");
}
return org;
}
// ── Owner: trigger verification ───────────────────────────────────────────
@Transactional
public OrgResponse verify(UUID id, String apiKeyHeader) {
Instant now = clock.now();
OrganizationEntity org = requireOrg(id);
requireOrgKey(org, apiKeyHeader);
if (org.verificationStatus == VerificationStatus.ACHIEVED
|| org.verificationStatus == VerificationStatus.MANUAL_REVIEW) {
return OrgResponse.from(org, now);
}
VerificationResult result = pipeline().run(
org.targetOLevel, org.domain, org.dnsVerificationToken,
org.registrantName, org.registrantJurisdiction);
applyVerificationResult(org, result, now);
org.updatedAt = now;
return OrgResponse.from(org, now);
}
// ── Owner: request upgrade to a higher level ──────────────────────────────
@Transactional
public OrgResponse requestUpgrade(UUID id, OrgUpgradeRequest req, String apiKeyHeader) {
Instant now = clock.now();
OrganizationEntity org = requireOrg(id);
requireOrgKey(org, apiKeyHeader);
if (req.targetOLevel().ordinal() <= org.earnedOLevel.ordinal()) {
throw unprocessable("targetOLevel must be higher than current earnedOLevel");
}
OLevel previousTarget = org.targetOLevel;
org.targetOLevel = req.targetOLevel();
org.verificationStatus = req.targetOLevel().ordinal() >= OLevel.OPERATIONALLY_VERIFIED.ordinal()
? VerificationStatus.MANUAL_REVIEW : VerificationStatus.PENDING;
org.verificationStep = null;
org.verificationError = null;
// Regenerate DNS token for new target if DNS-requiring level
if (req.targetOLevel().ordinal() >= OLevel.IDENTITY_VERIFIED.ordinal()
&& org.dnsVerificationToken == null) {
org.dnsVerificationToken = generateToken();
}
org.updatedAt = now;
recordEvent(org.id, OrgEventType.UPGRADE_REQUESTED, previousTarget, req.targetOLevel(),
"OWNER", "upgrade requested to " + req.targetOLevel(), now);
return OrgResponse.from(org, now);
}
// ── Admin: assign earned level manually (O-4+) ────────────────────────────
@Transactional
public OrgResponse assignEarnedLevel(UUID id, OLevel level, String adminKeyHeader) {
Instant now = clock.now();
requireAdminKey(adminKeyHeader);
OrganizationEntity org = requireOrg(id);
OLevel previous = org.earnedOLevel;
org.earnedOLevel = level;
org.verificationStatus = VerificationStatus.ACHIEVED;
org.verificationStep = null;
org.verificationError = null;
org.updatedAt = now;
recordEvent(org.id, OrgEventType.LEVEL_EARNED, previous, level, "BSF_ADMIN", "manual assignment", now);
return OrgResponse.from(org, now);
}
// ── Admin: grant temporary elevated level ─────────────────────────────────
@Transactional
public OrgResponse grantTemp(UUID id, OrgTempGrantRequest req, String adminKeyHeader) {
Instant now = clock.now();
requireAdminKey(adminKeyHeader);
if (req.expiresAt().isBefore(now)) {
throw unprocessable("expiresAt must be in the future");
}
OrganizationEntity org = requireOrg(id);
org.tempMaxOLevel = req.tempMaxOLevel();
org.tempMaxExpiresAt = req.expiresAt();
org.tempMaxGrantedBy = req.grantedBy();
org.tempMaxGrantedReason = req.reason();
org.updatedAt = now;
recordEvent(org.id, OrgEventType.TEMP_GRANTED, org.earnedOLevel, req.tempMaxOLevel(),
"BSF_ADMIN", "expires " + req.expiresAt() + "; reason: " + req.reason(), now);
return OrgResponse.from(org, now);
}
// ── Admin: revoke temporary grant ─────────────────────────────────────────
@Transactional
public OrgResponse revokeTemp(UUID id, String adminKeyHeader) {
Instant now = clock.now();
requireAdminKey(adminKeyHeader);
OrganizationEntity org = requireOrg(id);
if (org.tempMaxOLevel == null) {
throw unprocessable("no active temporary grant to revoke");
}
OLevel revoked = org.tempMaxOLevel;
org.tempMaxOLevel = null;
org.tempMaxExpiresAt = null;
org.tempMaxGrantedBy = null;
org.tempMaxGrantedReason = null;
org.updatedAt = now;
recordEvent(org.id, OrgEventType.TEMP_REVOKED, revoked, org.earnedOLevel,
"BSF_ADMIN", "grant revoked", now);
return OrgResponse.from(org, now);
}
// ── Owner: self-service key rotation — step 1: rotation secret → TAN ────────
// The rotation secret proves intent; the TAN (sent to registered email) confirms
// it is the legitimate owner. An attacker with only the rotation secret cannot
// complete the rotation without intercepting the registrant's email.
@Transactional
public Map<String, Object> rotateKey(UUID id, String rotationSecretHeader) {
Instant now = clock.now();
OrganizationEntity org = requireOrg(id);
if (!hash(rotationSecretHeader).equals(org.rotationSecretHash)) {
throw new ForbiddenException("invalid rotation secret");
}
if (org.fraudLocked) {
throw unprocessable("Key rotation is locked due to a fraud report. Contact admin@api-index.org to unlock.");
}
resetTanCountIfNeeded(org, now);
if (org.tanRequestCount24h >= 3) {
throw unprocessable("TAN request limit reached (3 per 24h). Contact admin@api-index.org.");
}
String tan = generateTan();
org.pendingTanHash = hash(tan);
org.pendingTanExpires = now.plus(Duration.ofMinutes(5));
org.tanRequestCount24h++;
org.tanLastRequestedAt = now;
org.updatedAt = now;
recordEvent(org.id, OrgEventType.TAN_ISSUED, null, null, "SYSTEM", "key rotation 2FA TAN", now);
String message = buildRotationWarning(org.registrantName, org.id, now);
Map<String, Object> signedNotification = mailSigner.signedNotification(
org.id, "TAN_ISSUED", now.toString(), message);
if (exposeTanInResponse) {
return Map.of("message", message, "tan", tan, "signedNotification", signedNotification);
}
return Map.of("message", message, "signedNotification", signedNotification);
}
private static String buildRotationWarning(String orgName, UUID orgId, Instant requestedAt) {
return String.format(
"Key rotation requested for \"%s\" at %s UTC. " +
"A TAN has been sent to the registered email address. " +
"Submit the TAN to /organizations/%s/rotate-key-with-tan within 5 minutes to complete the rotation. " +
"Did not initiate this? Report fraud immediately: " +
"POST /organizations/%s/notify-key-rotation-fraud",
orgName, requestedAt.toString().replace("T", " ").replace("Z", ""),
orgId, orgId);
}
// ── DNS-challenge key rotation (bot-friendly, ACME DNS-01 analogy) ────────
//
// Step 1: agent presents rotation secret → system issues a challenge token.
// Agent publishes "_apix-rotation.{domain}" TXT "apix-rotate={token}" via its DNS API.
// Step 2: agent presents rotation secret again → system queries DoH, rotates on match.
//
// The challenge token is public by design (it lives in DNS). Security comes from
// requiring the rotation secret on BOTH calls: DNS control alone is insufficient.
// Notification emails are sent at both steps; the token is anonymised in all messages
// so the owner can identify the rotation without the token being useful to a reader.
@Transactional
public Map<String, Object> initiateKeyRotationDns(UUID id, String rotationSecretHeader) {
Instant now = clock.now();
OrganizationEntity org = requireOrg(id);
if (!hash(rotationSecretHeader).equals(org.rotationSecretHash)) {
throw new ForbiddenException("invalid rotation secret");
}
if (org.fraudLocked) {
throw unprocessable("Key rotation is locked due to a fraud report. Contact admin@api-index.org to unlock.");
}
String challengeToken = generateToken();
org.pendingRotationChallengeToken = challengeToken;
org.pendingRotationChallengeExpires = now.plus(Duration.ofMinutes(15));
org.updatedAt = now;
String anon = anonymizeToken(challengeToken);
String notificationMsg = String.format(
"DNS-challenge key rotation initiated for \"%s\" at %s UTC " +
"(challenge token: %s). " +
"A notification has been sent to the registered email address. " +
"Did not initiate this? Report fraud: POST /organizations/%s/notify-key-rotation-fraud",
org.registrantName,
now.toString().replace("T", " ").replace("Z", ""),
anon, id);
recordEvent(org.id, OrgEventType.DNS_ROTATION_INITIATED, null, null,
"SYSTEM", "DNS-challenge rotation initiated; anon token: " + anon, now);
Map<String, Object> signedNotification = mailSigner.signedNotification(
org.id, "DNS_ROTATION_INITIATED", now.toString(), notificationMsg);
return Map.of(
"challengeToken", challengeToken,
"dnsRecord", "_apix-rotation." + org.domain,
"dnsValue", "apix-rotate=" + challengeToken,
"expiresAt", org.pendingRotationChallengeExpires.toString(),
"message", String.format(
"Publish TXT record \"%s\" with value \"%s\" " +
"at your DNS provider, then call /organizations/%s/confirm-key-rotation-dns " +
"with your rotation secret within 15 minutes.",
"_apix-rotation." + org.domain, "apix-rotate=" + challengeToken, id),
"notification", notificationMsg,
"signedNotification", signedNotification);
}
@Transactional
public OrgKeyRotateResponse confirmKeyRotationDns(UUID id, String rotationSecretHeader) {
Instant now = clock.now();
OrganizationEntity org = requireOrg(id);
if (!hash(rotationSecretHeader).equals(org.rotationSecretHash)) {
throw new ForbiddenException("invalid rotation secret");
}
if (org.fraudLocked) {
throw unprocessable("Key rotation is locked due to a fraud report. Contact admin@api-index.org to unlock.");
}
if (org.pendingRotationChallengeToken == null
|| org.pendingRotationChallengeExpires == null
|| !now.isBefore(org.pendingRotationChallengeExpires)) {
throw unprocessable("No valid DNS rotation challenge found. Call /rotate-key-dns first.");
}
String completedToken = org.pendingRotationChallengeToken;
boolean verified;
try {
verified = rotationChallengeVerifier().verify(org.domain, completedToken);
} catch (RuntimeException e) {
throw unprocessable("DNS verification failed: " + e.getMessage());
}
if (!verified) {
throw unprocessable(
"DNS TXT record not found. Ensure \"_apix-rotation." + org.domain + "\" " +
"contains \"apix-rotate=" + completedToken + "\" and retry.");
}
String newApiKey = generateKey("apix_org_");
String newRotationSecret = generateKey("apix_rot_");
org.apiKeyHash = hash(newApiKey);
org.rotationSecretHash = hash(newRotationSecret);
org.pendingRotationChallengeToken = null;
org.pendingRotationChallengeExpires = null;
org.updatedAt = now;
String anon = anonymizeToken(completedToken);
recordEvent(org.id, OrgEventType.KEY_ROTATED, null, null,
"OWNER", "DNS-challenge rotation completed; anon token: " + anon, now);
Map<String, Object> notification = mailSigner.signedNotification(
org.id, "KEY_ROTATED", now.toString(),
String.format("Keys successfully rotated via DNS challenge at %s UTC. Challenge: %s",
now.toString().replace("T", " ").replace("Z", ""), anon));
return new OrgKeyRotateResponse(newApiKey, newRotationSecret, OrgKeyRotateResponse.KEY_WARNING, notification);
}
private RotationChallengeVerifier rotationChallengeVerifier() {
return new RotationChallengeVerifier(new VerificationConfig(
dohUrl, gleifApiUrl, ocApiKey.orElse(""), ocApiUrl, securityTxtTemplate, httpTimeoutMs));
}
// ── Emergency: request TAN (rotation secret compromised) ─────────────────
@Transactional
public Map<String, Object> requestTan(UUID id, OrgTanRequestBody body) {
Instant now = clock.now();
OrganizationEntity org = requireOrg(id);
// Silent on email mismatch — never confirm whether org exists or email matches
if (!org.registrantEmail.equalsIgnoreCase(body.email())) {
return Map.of("message", "If the email matches the registered address, a TAN has been sent.");
}
// Fraud lock: confirmed to the owner but not to random callers (email gate above)
if (org.fraudLocked) {
throw unprocessable("Key rotation is locked due to a fraud report. Contact admin@api-index.org to unlock.");
}
resetTanCountIfNeeded(org, now);
if (org.tanRequestCount24h >= 3) {
throw unprocessable("TAN request limit reached (3 per 24h). Contact admin@api-index.org.");
}
String tan = generateTan();
org.pendingTanHash = hash(tan);
org.pendingTanExpires = now.plus(Duration.ofMinutes(5));
org.tanRequestCount24h++;
org.tanLastRequestedAt = now;
org.updatedAt = now;
recordEvent(org.id, OrgEventType.TAN_ISSUED, null, null, "SYSTEM", "emergency key rotation TAN", now);
// In test mode only — expose TAN in response to allow BDD verification
if (exposeTanInResponse) {
return Map.of("message", "TAN sent to registered email.", "tan", tan);
}
return Map.of("message", "If the email matches the registered address, a TAN has been sent.");
}
// ── Emergency: rotate keys using TAN ──────────────────────────────────────
@Transactional
public OrgKeyRotateResponse rotateKeyWithTan(UUID id, OrgTanRotateRequest body) {
Instant now = clock.now();
OrganizationEntity org = requireOrg(id);
if (org.fraudLocked) {
throw unprocessable("Key rotation is locked due to a fraud report. Contact admin@api-index.org to unlock.");
}
if (org.pendingTanHash == null
|| org.pendingTanExpires == null
|| !now.isBefore(org.pendingTanExpires)) {
throw unprocessable("no valid TAN for this organisation");
}
if (!hash(body.tan()).equals(org.pendingTanHash)) {
throw new ForbiddenException("invalid TAN");
}
String newApiKey = generateKey("apix_org_");
String newRotationSecret = generateKey("apix_rot_");
org.apiKeyHash = hash(newApiKey);
org.rotationSecretHash = hash(newRotationSecret);
org.pendingTanHash = null;
org.pendingTanExpires = null;
org.updatedAt = now;
recordEvent(org.id, OrgEventType.KEY_ROTATED, null, null,
"OWNER", "TAN rotation completed at " + now, now);
Map<String, Object> notification = mailSigner.signedNotification(
org.id, "KEY_ROTATED", now.toString(),
String.format("Keys successfully rotated via TAN at %s UTC",
now.toString().replace("T", " ").replace("Z", "")));
return new OrgKeyRotateResponse(newApiKey, newRotationSecret, OrgKeyRotateResponse.KEY_WARNING, notification);
}
// ── Admin: clear fraud lock after investigation ───────────────────────────
@Transactional
public OrgResponse clearFraudLock(UUID id, String adminKeyHeader) {
Instant now = clock.now();
requireAdminKey(adminKeyHeader);
OrganizationEntity org = requireOrg(id);
if (!org.fraudLocked) {
throw unprocessable("no active fraud lock to clear");
}
org.fraudLocked = false;
org.fraudLockedAt = null;
org.updatedAt = now;
recordEvent(org.id, OrgEventType.FRAUD_LOCK_CLEARED, null, null,
"BSF_ADMIN", "fraud lock cleared after investigation", now);
return OrgResponse.from(org, now);
}
// ── Fraud report — no auth required, always returns generic confirmation ────
// Matching email: clears any pending TAN immediately, sets fraud lock, records event.
// Mismatched email: silently ignored to prevent org-existence enumeration.
// Fraud lock blocks all subsequent rotation attempts until cleared by BSF admin.
@Transactional
public Map<String, Object> reportFraud(UUID id, OrgFraudReportRequest req) {
Instant now = clock.now();
OrganizationEntity org = requireOrg(id);
if (org.registrantEmail.equalsIgnoreCase(req.email())) {
org.pendingTanHash = null;
org.pendingTanExpires = null;
org.pendingRotationChallengeToken = null;
org.pendingRotationChallengeExpires = null;
org.fraudLocked = true;
org.fraudLockedAt = now;
org.updatedAt = now;
recordEvent(org.id, OrgEventType.FRAUD_REPORTED, null, null,
"OWNER", "fraud report: " + (req.notes() != null ? req.notes() : "no notes"), now);
}
return Map.of("message",
"Fraud notification received. If the email matches our records, " +
"the pending rotation has been cancelled and our security team has been alerted.");
}
// ── Helpers ───────────────────────────────────────────────────────────────
private OrganizationEntity requireOrg(UUID id) {
OrganizationEntity org = em.find(OrganizationEntity.class, id);
if (org == null) throw new NotFoundException("organisation not found");
return org;
}
private void requireOrgKey(OrganizationEntity org, String header) {
if (header == null || !hash(header).equals(org.apiKeyHash)) {
throw new ForbiddenException("invalid org API key");
}
}
private void requireAdminKey(String header) {
if (!adminApiKey.equals(header)) {
throw new ForbiddenException("invalid admin API key");
}
}
private void applyVerificationResult(OrganizationEntity org, VerificationResult result, Instant now) {
OLevel previous = org.earnedOLevel;
if (result.succeeded()) {
org.earnedOLevel = result.oLevelAchieved();
org.verificationStatus = VerificationStatus.ACHIEVED;
org.verificationStep = null;
org.verificationError = null;
if (result.detectedLei() != null) org.lei = result.detectedLei();
recordEvent(org.id, OrgEventType.LEVEL_EARNED, previous, result.oLevelAchieved(), "SYSTEM", null, now);
} else {
org.earnedOLevel = result.oLevelAchieved();
org.verificationStatus = VerificationStatus.FAILED;
org.verificationStep = result.blockedAtStep();
org.verificationError = result.message();
recordEvent(org.id, OrgEventType.VERIFICATION_FAILED, previous, result.oLevelAchieved(),
"SYSTEM", result.blockedAtStep() + ": " + result.message(), now);
}
}
private void recordEvent(UUID orgId, OrgEventType type, OLevel from, OLevel to,
String triggeredBy, String notes, Instant now) {
OrgVerificationEventEntity ev = new OrgVerificationEventEntity();
ev.id = UUID.randomUUID();
ev.organizationId = orgId;
ev.eventType = type;
ev.fromLevel = from;
ev.toLevel = to;
ev.triggeredBy = triggeredBy;
ev.notes = notes;
ev.createdAt = now;
em.persist(ev);
}
private void resetTanCountIfNeeded(OrganizationEntity org, Instant now) {
if (org.tanLastRequestedAt != null
&& Duration.between(org.tanLastRequestedAt, now).toHours() >= 24) {
org.tanRequestCount24h = 0;
}
}
private VerificationPipeline pipeline() {
return new VerificationPipeline(new VerificationConfig(
dohUrl, gleifApiUrl, ocApiKey.orElse(""), ocApiUrl, securityTxtTemplate, httpTimeoutMs));
}
private static WebApplicationException unprocessable(String message) {
return new WebApplicationException(
Response.status(422).entity(Map.of("message", message)).build());
}
static String generateKey(String prefix) {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return prefix + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
static String generateToken() {
byte[] bytes = new byte[24];
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
static String generateTan() {
// 6 chars, no ambiguous characters (0/O, 1/I)
String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
SecureRandom rng = new SecureRandom();
StringBuilder sb = new StringBuilder(6);
for (int i = 0; i < 6; i++) sb.append(chars.charAt(rng.nextInt(chars.length())));
return sb.toString();
}
static String anonymizeToken(String token) {
if (token == null || token.length() <= 8) return "****";
return token.substring(0, 4) + "..." + token.substring(token.length() - 4);
}
static String hash(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(64);
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
}
@@ -23,8 +23,22 @@ quarkus.http.port=8180
apix.api-key=${APIX_API_KEY:dev-insecure-key-change-in-prod}
# ── Verification ──────────────────────────────────────────────────────────────
apix.dns.doh-url=${APIX_DOH_URL:https://dns.google/resolve}
apix.gleif.api-url=${GLEIF_API_URL:https://api.gleif.org/api/v1}
apix.opencorporates.api-key=${OPENCORPORATES_API_KEY:}
apix.opencorporates.api-url=${OPENCORPORATES_API_URL:https://api.opencorporates.com/v0.4}
apix.hygiene.security-txt-url-template=${APIX_SECURITY_TXT_TEMPLATE:https://{domain}/.well-known/security.txt}
apix.verification.http-timeout-ms=${APIX_VERIFICATION_TIMEOUT_MS:5000}
apix.org.tan.expose-in-response=false
# ── Mail signing (Ed25519) ────────────────────────────────────────────────────
# PKCS#8 private key and SubjectPublicKeyInfo public key, Base64 standard encoding.
# If blank, an ephemeral key pair is generated at startup (dev/test only).
apix.mail.signing.private-key-base64=${APIX_MAIL_SIGNING_PRIVATE_KEY:}
apix.mail.signing.public-key-base64=${APIX_MAIL_SIGNING_PUBLIC_KEY:}
# Key ID published in signed payloads and the /mail-signing-keys endpoint.
# Convention: YYYY-MM, rotated every 6 months.
apix.mail.signing.kid=${APIX_MAIL_SIGNING_KID:dev}
apix.sanctions.cache-path=${SANCTIONS_CACHE_PATH:./sanctions-cache}
# ── Logging ───────────────────────────────────────────────────────────────────
@@ -33,3 +47,30 @@ quarkus.log.console.json=false
# ── Health ────────────────────────────────────────────────────────────────────
quarkus.smallrye-health.root-path=/q/health
# ── Observability ─────────────────────────────────────────────────────────────
# HTTP server request metrics (http_server_requests_seconds histogram) are
# auto-instrumented by quarkus-micrometer. Prometheus scrapes /q/metrics.
quarkus.micrometer.enabled=true
quarkus.micrometer.export.prometheus.enabled=true
# Without match-patterns, the uri label is the raw request path and UUIDs create
# unbounded cardinality. Order matters: specific sub-paths before the catch-all.
quarkus.micrometer.binder.http-server.match-patterns=\
/services/[0-9a-f-]+/replacements=/services/{id}/replacements,\
/services/[0-9a-f-]+/history=/services/{id}/history,\
/services/[0-9a-f-]+/olevel=/services/{id}/olevel,\
/services/[0-9a-f-]+=/services/{id},\
/organizations/[0-9a-f-]+/verify=/organizations/{id}/verify,\
/organizations/[0-9a-f-]+/request-upgrade=/organizations/{id}/request-upgrade,\
/organizations/[0-9a-f-]+/earned-level=/organizations/{id}/earned-level,\
/organizations/[0-9a-f-]+/temp-grant=/organizations/{id}/temp-grant,\
/organizations/[0-9a-f-]+/rotate-key=/organizations/{id}/rotate-key,\
/organizations/[0-9a-f-]+/request-tan=/organizations/{id}/request-tan,\
/organizations/[0-9a-f-]+/rotate-key-with-tan=/organizations/{id}/rotate-key-with-tan,\
/organizations/[0-9a-f-]+/rotate-key-dns=/organizations/{id}/rotate-key-dns,\
/organizations/[0-9a-f-]+/confirm-key-rotation-dns=/organizations/{id}/confirm-key-rotation-dns,\
/organizations/[0-9a-f-]+/notify-key-rotation-fraud=/organizations/{id}/notify-key-rotation-fraud,\
/organizations/[0-9a-f-]+/fraud-lock=/organizations/{id}/fraud-lock,\
/organizations/[0-9a-f-]+/audit-log=/organizations/{id}/audit-log,\
/organizations/[0-9a-f-]+/event-feed=/organizations/{id}/event-feed,\
/organizations/[0-9a-f-]+=/organizations/{id}
@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="010" author="apix">
<createTable tableName="organizations">
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="registrant_name" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="registrant_email" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="registrant_jurisdiction" type="varchar(10)">
<constraints nullable="false"/>
</column>
<column name="registrant_org_type" type="varchar(50)">
<constraints nullable="false"/>
</column>
<!-- Domain used for DNS and hygiene verification -->
<column name="domain" type="varchar(255)">
<constraints nullable="false"/>
</column>
<!-- GLEIF LEI — populated during O-2 if a GLEIF record is found -->
<column name="lei" type="varchar(20)"/>
<!-- Credentials — stored as SHA-256 hex hashes, never in plaintext -->
<column name="api_key_hash" type="varchar(64)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_org_api_key_hash"/>
</column>
<!-- Rotation secret exposure → always triggers admin+TAN process -->
<column name="rotation_secret_hash" type="varchar(64)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_org_rotation_secret_hash"/>
</column>
<!-- O-level state machine -->
<column name="target_o_level" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="earned_o_level" type="varchar(50)" defaultValue="UNVERIFIED">
<constraints nullable="false"/>
</column>
<!-- PENDING | VERIFYING | ACHIEVED | FAILED | MANUAL_REVIEW -->
<column name="verification_status" type="varchar(50)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<!-- Which step is in progress or last failed -->
<column name="verification_step" type="varchar(100)"/>
<column name="verification_error" type="text"/>
<!-- Random token org owner must publish as DNS TXT _apix-verification.{domain} -->
<column name="dns_verification_token" type="varchar(64)"/>
<!-- BSF temporary elevated grant -->
<column name="temp_max_o_level" type="varchar(50)"/>
<column name="temp_max_expires_at" type="timestamptz"/>
<column name="temp_max_granted_by" type="varchar(255)"/>
<column name="temp_max_granted_reason" type="text"/>
<!-- Emergency key rotation via admin TAN (one active TAN per org) -->
<column name="pending_tan_hash" type="varchar(64)"/>
<column name="pending_tan_expires_at" type="timestamptz"/>
<!-- Rate-limit: max 3 TAN requests per 24h -->
<column name="tan_request_count_24h" type="integer" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="tan_last_requested_at" type="timestamptz"/>
<!-- Fraud lock: set by /notify-key-rotation-fraud; blocks all rotation until cleared by BSF admin -->
<column name="fraud_locked" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="fraud_locked_at" type="timestamptz"/>
<column name="created_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
<column name="updated_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
<!-- Append-only audit trail of every O-level change event -->
<createTable tableName="org_verification_events">
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="organization_id" type="uuid">
<constraints nullable="false"
foreignKeyName="fk_org_event_org"
references="organizations(id)"
deleteCascade="true"/>
</column>
<!-- REGISTERED | LEVEL_EARNED | UPGRADE_REQUESTED | VERIFICATION_FAILED |
TEMP_GRANTED | TEMP_REVOKED | TEMP_EXPIRED | LEVEL_REVOKED |
KEY_ROTATED | TAN_ISSUED | DNS_ROTATION_INITIATED |
FRAUD_REPORTED | FRAUD_LOCK_CLEARED -->
<column name="event_type" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="from_level" type="varchar(50)"/>
<column name="to_level" type="varchar(50)"/>
<!-- SYSTEM | OWNER | BSF_ADMIN -->
<column name="triggered_by" type="varchar(50)"/>
<column name="notes" type="text"/>
<column name="created_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="org_verification_events" indexName="idx_org_events_org_id">
<column name="organization_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="011" author="apix">
<!-- DNS-challenge key rotation fields (bot-friendly, ACME DNS-01 analogy).
Token is stored plaintext — it is a public DNS value, not a secret. -->
<addColumn tableName="organizations">
<column name="pending_rotation_challenge_token" type="varchar(64)"/>
<column name="pending_rotation_challenge_expires_at" type="timestamptz"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -14,5 +14,7 @@
<include file="changes/007-service-replacements.xml" relativeToChangelogFile="true"/>
<include file="changes/008-sunset-at.xml" relativeToChangelogFile="true"/>
<include file="changes/009-iot-profiles.xml" relativeToChangelogFile="true"/>
<include file="changes/010-organizations.xml" relativeToChangelogFile="true"/>
<include file="changes/011-org-rotation-challenge.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>