ops: add CI/CD pipeline, a/b rolling deploy, Gitea Actions workflow
Deploy to Production / deploy (push) Failing after 10s
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:
@@ -0,0 +1,17 @@
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: [self-hosted, host]
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Deploy (rolling zero-downtime)
|
||||
run: bash scripts/deploy-bluegreen.sh
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.botstandards.apix.common;
|
||||
|
||||
public enum OrgEventType {
|
||||
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
|
||||
}
|
||||
@@ -3,5 +3,22 @@ package org.botstandards.apix.common;
|
||||
public record VerificationResult(
|
||||
OLevel oLevelAchieved,
|
||||
String blockedAtStep,
|
||||
String message
|
||||
) {}
|
||||
String message,
|
||||
String detectedLei
|
||||
) {
|
||||
public static VerificationResult success(OLevel level) {
|
||||
return new VerificationResult(level, null, null, null);
|
||||
}
|
||||
|
||||
public static VerificationResult success(OLevel level, String lei) {
|
||||
return new VerificationResult(level, null, null, lei);
|
||||
}
|
||||
|
||||
public static VerificationResult failure(OLevel partialLevel, String step, String message) {
|
||||
return new VerificationResult(partialLevel, step, message, null);
|
||||
}
|
||||
|
||||
public boolean succeeded() {
|
||||
return blockedAtStep == null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.botstandards.apix.common;
|
||||
|
||||
public enum VerificationStatus {
|
||||
PENDING,
|
||||
VERIFYING,
|
||||
ACHIEVED,
|
||||
FAILED,
|
||||
MANUAL_REVIEW,
|
||||
SUSPENDED
|
||||
}
|
||||
@@ -55,6 +55,12 @@
|
||||
<artifactId>quarkus-logging-json</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Observability -->
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-micrometer-registry-prometheus</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Validation / Security / Health -->
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
@@ -118,6 +124,63 @@
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<!-- Adds src/integration-test as test source/resource root -->
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>build-helper-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>add-integration-test-source</id>
|
||||
<phase>generate-test-sources</phase>
|
||||
<goals><goal>add-test-source</goal></goals>
|
||||
<configuration>
|
||||
<sources>
|
||||
<source>src/integration-test/java</source>
|
||||
</sources>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>add-integration-test-resource</id>
|
||||
<phase>generate-test-resources</phase>
|
||||
<goals><goal>add-test-resource</goal></goals>
|
||||
<configuration>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/integration-test/resources</directory>
|
||||
</resource>
|
||||
</resources>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<!-- Unit tests only — exclude IT classes so they don't run in the test phase -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>**/*IT.java</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<!-- Integration tests run in integration-test/verify phases -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>integration-test</goal>
|
||||
<goal>verify</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<includes>
|
||||
<include>**/*IT.java</include>
|
||||
</includes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>${quarkus.platform.group-id}</groupId>
|
||||
<artifactId>quarkus-maven-plugin</artifactId>
|
||||
|
||||
+2
-2
@@ -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() {
|
||||
+1
-1
@@ -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() {
|
||||
+49
@@ -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));
|
||||
}
|
||||
}
|
||||
+27
@@ -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");
|
||||
}
|
||||
}
|
||||
+918
@@ -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)));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -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;
|
||||
}
|
||||
}
|
||||
+10
@@ -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
|
||||
+38
@@ -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
|
||||
+87
@@ -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"
|
||||
+153
@@ -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
|
||||
+93
@@ -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"
|
||||
+50
@@ -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
|
||||
+63
@@ -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
|
||||
+57
@@ -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
|
||||
+48
@@ -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"
|
||||
+59
@@ -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"
|
||||
+2
-2
@@ -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_
|
||||
+26
@@ -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) {}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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) {}
|
||||
+11
@@ -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
|
||||
) {}
|
||||
+13
@@ -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.";
|
||||
}
|
||||
+17
@@ -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
|
||||
) {}
|
||||
+25
@@ -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) {}
|
||||
+7
@@ -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) {}
|
||||
+16
@@ -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) {}
|
||||
+42
@@ -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;
|
||||
}
|
||||
+117
@@ -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;
|
||||
}
|
||||
}
|
||||
+29
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -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()
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
+197
@@ -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();
|
||||
}
|
||||
}
|
||||
+159
@@ -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");
|
||||
}
|
||||
}
|
||||
+649
@@ -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>
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package org.botstandards.apix.verification;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.botstandards.apix.common.OLevel;
|
||||
import org.botstandards.apix.common.VerificationResult;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
public class O1DnsVerifier {
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
record DohResponse(int Status, List<DohRecord> Answer) {
|
||||
DohResponse() { this(0, List.of()); }
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
record DohRecord(String data) {
|
||||
DohRecord() { this(null); }
|
||||
}
|
||||
|
||||
private final VerificationConfig config;
|
||||
private final HttpClient http;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public O1DnsVerifier(VerificationConfig config) {
|
||||
this.config = config;
|
||||
this.http = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofMillis(config.httpTimeoutMs()))
|
||||
.build();
|
||||
this.mapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
public VerificationResult verify(String domain, String expectedToken) {
|
||||
VerificationResult txtResult = checkTxtRecord(domain, expectedToken);
|
||||
if (!txtResult.succeeded()) {
|
||||
return txtResult;
|
||||
}
|
||||
return checkMxRecord(domain);
|
||||
}
|
||||
|
||||
private VerificationResult checkTxtRecord(String domain, String expectedToken) {
|
||||
String url = config.dohUrl() + "?name=_apix-verification." + domain + "&type=TXT";
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(Duration.ofMillis(config.httpTimeoutMs()))
|
||||
.header("Accept", "application/dns-json")
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
DohResponse doh = mapper.readValue(response.body(), DohResponse.class);
|
||||
|
||||
if (doh.Status() == 3) {
|
||||
return VerificationResult.failure(OLevel.UNVERIFIED, "DNS_TXT",
|
||||
"NXDOMAIN for _apix-verification." + domain);
|
||||
}
|
||||
if (doh.Answer() == null || doh.Answer().isEmpty()) {
|
||||
return VerificationResult.failure(OLevel.UNVERIFIED, "DNS_TXT",
|
||||
"No TXT record found at _apix-verification." + domain);
|
||||
}
|
||||
|
||||
String expected = "apix-token=" + expectedToken;
|
||||
boolean found = doh.Answer().stream()
|
||||
.map(DohRecord::data)
|
||||
.filter(d -> d != null)
|
||||
.map(d -> d.startsWith("\"") && d.endsWith("\"") ? d.substring(1, d.length() - 1) : d)
|
||||
.anyMatch(d -> d.equals(expected));
|
||||
|
||||
if (!found) {
|
||||
return VerificationResult.failure(OLevel.UNVERIFIED, "DNS_TXT",
|
||||
"TXT record at _apix-verification." + domain + " does not contain " + expected);
|
||||
}
|
||||
return VerificationResult.success(OLevel.IDENTITY_VERIFIED);
|
||||
} catch (Exception e) {
|
||||
return VerificationResult.failure(OLevel.UNVERIFIED, "DNS_TXT",
|
||||
"HTTP error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private VerificationResult checkMxRecord(String domain) {
|
||||
String url = config.dohUrl() + "?name=" + domain + "&type=MX";
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(Duration.ofMillis(config.httpTimeoutMs()))
|
||||
.header("Accept", "application/dns-json")
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
DohResponse doh = mapper.readValue(response.body(), DohResponse.class);
|
||||
|
||||
if (doh.Answer() == null || doh.Answer().isEmpty()) {
|
||||
return VerificationResult.failure(OLevel.UNVERIFIED, "DNS_MX",
|
||||
"No MX record found for " + domain);
|
||||
}
|
||||
return VerificationResult.success(OLevel.IDENTITY_VERIFIED);
|
||||
} catch (Exception e) {
|
||||
return VerificationResult.failure(OLevel.UNVERIFIED, "DNS_MX",
|
||||
"HTTP error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package org.botstandards.apix.verification;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.botstandards.apix.common.OLevel;
|
||||
import org.botstandards.apix.common.VerificationResult;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
public class O2GleifVerifier {
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
record GleifResponse(List<GleifRecord> data) {
|
||||
GleifResponse() { this(List.of()); }
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
record GleifRecord(String id) {
|
||||
GleifRecord() { this(null); }
|
||||
}
|
||||
|
||||
private final VerificationConfig config;
|
||||
private final HttpClient http;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public O2GleifVerifier(VerificationConfig config) {
|
||||
this.config = config;
|
||||
this.http = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofMillis(config.httpTimeoutMs()))
|
||||
.build();
|
||||
this.mapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
public VerificationResult verify(String legalName, String jurisdiction) {
|
||||
String encodedName = URLEncoder.encode(legalName, StandardCharsets.UTF_8);
|
||||
String url = config.gleifApiUrl() + "/lei-records"
|
||||
+ "?filter[entity.legalName]=" + encodedName
|
||||
+ "&filter[entity.jurisdiction]=" + jurisdiction;
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(Duration.ofMillis(config.httpTimeoutMs()))
|
||||
.header("Accept", "application/json")
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
GleifResponse gleif = mapper.readValue(response.body(), GleifResponse.class);
|
||||
if (gleif.data() != null && !gleif.data().isEmpty()) {
|
||||
String lei = gleif.data().get(0).id();
|
||||
return VerificationResult.success(OLevel.LEGAL_ENTITY_VERIFIED, lei);
|
||||
}
|
||||
}
|
||||
return VerificationResult.failure(OLevel.IDENTITY_VERIFIED, "GLEIF",
|
||||
"No GLEIF record found");
|
||||
} catch (Exception e) {
|
||||
return VerificationResult.failure(OLevel.IDENTITY_VERIFIED, "GLEIF",
|
||||
"HTTP error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package org.botstandards.apix.verification;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.botstandards.apix.common.OLevel;
|
||||
import org.botstandards.apix.common.VerificationResult;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
public class O2OpenCorporatesVerifier {
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
record OcResponse(OcResults results) {
|
||||
OcResponse() { this(null); }
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
record OcResults(List<Object> companies) {
|
||||
OcResults() { this(List.of()); }
|
||||
}
|
||||
|
||||
private final VerificationConfig config;
|
||||
private final HttpClient http;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public O2OpenCorporatesVerifier(VerificationConfig config) {
|
||||
this.config = config;
|
||||
this.http = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofMillis(config.httpTimeoutMs()))
|
||||
.build();
|
||||
this.mapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
public VerificationResult verify(String legalName, String jurisdiction) {
|
||||
String encodedName = URLEncoder.encode(legalName, StandardCharsets.UTF_8);
|
||||
String url = config.openCorporatesApiUrl() + "/companies/search"
|
||||
+ "?q=" + encodedName
|
||||
+ "&jurisdiction_code=" + jurisdiction
|
||||
+ "&api_token=" + config.openCorporatesApiKey();
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(Duration.ofMillis(config.httpTimeoutMs()))
|
||||
.header("Accept", "application/json")
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
OcResponse oc = mapper.readValue(response.body(), OcResponse.class);
|
||||
if (oc.results() != null
|
||||
&& oc.results().companies() != null
|
||||
&& !oc.results().companies().isEmpty()) {
|
||||
return VerificationResult.success(OLevel.LEGAL_ENTITY_VERIFIED);
|
||||
}
|
||||
}
|
||||
return VerificationResult.failure(OLevel.IDENTITY_VERIFIED, "OPENCORPORATES",
|
||||
"No company record found");
|
||||
} catch (Exception e) {
|
||||
return VerificationResult.failure(OLevel.IDENTITY_VERIFIED, "OPENCORPORATES",
|
||||
"HTTP error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package org.botstandards.apix.verification;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.botstandards.apix.common.OLevel;
|
||||
import org.botstandards.apix.common.VerificationResult;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
public class O3HygieneVerifier {
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
record DohResponse(int Status, List<DohRecord> Answer) {
|
||||
DohResponse() { this(0, List.of()); }
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
record DohRecord(String data) {
|
||||
DohRecord() { this(null); }
|
||||
}
|
||||
|
||||
private final VerificationConfig config;
|
||||
private final HttpClient http;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public O3HygieneVerifier(VerificationConfig config) {
|
||||
this.config = config;
|
||||
this.http = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofMillis(config.httpTimeoutMs()))
|
||||
.build();
|
||||
this.mapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
public VerificationResult verify(String domain) {
|
||||
VerificationResult secResult = checkSecurityTxt(domain);
|
||||
if (!secResult.succeeded()) {
|
||||
return secResult;
|
||||
}
|
||||
VerificationResult dmarcResult = checkDmarc(domain);
|
||||
if (!dmarcResult.succeeded()) {
|
||||
return dmarcResult;
|
||||
}
|
||||
return checkSpf(domain);
|
||||
}
|
||||
|
||||
private VerificationResult checkSecurityTxt(String domain) {
|
||||
String url = config.securityTxtUrlTemplate().replace("{domain}", domain);
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(Duration.ofMillis(config.httpTimeoutMs()))
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
return VerificationResult.failure(OLevel.LEGAL_ENTITY_VERIFIED, "SECURITY_TXT",
|
||||
"security.txt not found at " + url + " (HTTP " + response.statusCode() + ")");
|
||||
}
|
||||
return VerificationResult.success(OLevel.HYGIENE_VERIFIED);
|
||||
} catch (Exception e) {
|
||||
return VerificationResult.failure(OLevel.LEGAL_ENTITY_VERIFIED, "SECURITY_TXT",
|
||||
"HTTP error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private VerificationResult checkDmarc(String domain) {
|
||||
String url = config.dohUrl() + "?name=_dmarc." + domain + "&type=TXT";
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(Duration.ofMillis(config.httpTimeoutMs()))
|
||||
.header("Accept", "application/dns-json")
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
DohResponse doh = mapper.readValue(response.body(), DohResponse.class);
|
||||
|
||||
boolean found = doh.Answer() != null && doh.Answer().stream()
|
||||
.map(DohRecord::data)
|
||||
.filter(d -> d != null)
|
||||
.map(d -> d.startsWith("\"") && d.endsWith("\"") ? d.substring(1, d.length() - 1) : d)
|
||||
.anyMatch(d -> d.contains("v=DMARC1"));
|
||||
|
||||
if (!found) {
|
||||
return VerificationResult.failure(OLevel.LEGAL_ENTITY_VERIFIED, "DMARC",
|
||||
"No DMARC TXT record found for _dmarc." + domain);
|
||||
}
|
||||
return VerificationResult.success(OLevel.HYGIENE_VERIFIED);
|
||||
} catch (Exception e) {
|
||||
return VerificationResult.failure(OLevel.LEGAL_ENTITY_VERIFIED, "DMARC",
|
||||
"HTTP error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private VerificationResult checkSpf(String domain) {
|
||||
String url = config.dohUrl() + "?name=" + domain + "&type=TXT";
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(Duration.ofMillis(config.httpTimeoutMs()))
|
||||
.header("Accept", "application/dns-json")
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
DohResponse doh = mapper.readValue(response.body(), DohResponse.class);
|
||||
|
||||
boolean found = doh.Answer() != null && doh.Answer().stream()
|
||||
.map(DohRecord::data)
|
||||
.filter(d -> d != null)
|
||||
.map(d -> d.startsWith("\"") && d.endsWith("\"") ? d.substring(1, d.length() - 1) : d)
|
||||
.anyMatch(d -> d.contains("v=spf1"));
|
||||
|
||||
if (!found) {
|
||||
return VerificationResult.failure(OLevel.LEGAL_ENTITY_VERIFIED, "SPF",
|
||||
"No SPF TXT record found for " + domain);
|
||||
}
|
||||
return VerificationResult.success(OLevel.HYGIENE_VERIFIED);
|
||||
} catch (Exception e) {
|
||||
return VerificationResult.failure(OLevel.LEGAL_ENTITY_VERIFIED, "SPF",
|
||||
"HTTP error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package org.botstandards.apix.verification;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Verifies that an agent has published the expected rotation challenge token
|
||||
* at the well-known DNS location: _apix-rotation.{domain} TXT "apix-rotate={token}"
|
||||
*
|
||||
* This is the machine-native key rotation path: rotation secret (first factor)
|
||||
* + DNS control (second factor, analogous to ACME DNS-01).
|
||||
* The challenge token is intentionally public — it is valueless without the rotation secret.
|
||||
*/
|
||||
public class RotationChallengeVerifier {
|
||||
|
||||
private static final String TXT_PREFIX = "apix-rotate=";
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
record DohResponse(int Status, List<DohRecord> Answer) {
|
||||
DohResponse() { this(0, List.of()); }
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
record DohRecord(String data) {
|
||||
DohRecord() { this(null); }
|
||||
}
|
||||
|
||||
private final VerificationConfig config;
|
||||
private final HttpClient http;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public RotationChallengeVerifier(VerificationConfig config) {
|
||||
this.config = config;
|
||||
this.http = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofMillis(config.httpTimeoutMs()))
|
||||
.build();
|
||||
this.mapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if _apix-rotation.{domain} TXT contains "apix-rotate={expectedToken}".
|
||||
* Throws on HTTP/parse error so the caller can surface a meaningful error.
|
||||
*/
|
||||
public boolean verify(String domain, String expectedToken) {
|
||||
String url = config.dohUrl() + "?name=_apix-rotation." + domain + "&type=TXT";
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(Duration.ofMillis(config.httpTimeoutMs()))
|
||||
.header("Accept", "application/dns-json")
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
DohResponse doh = mapper.readValue(response.body(), DohResponse.class);
|
||||
|
||||
if (doh.Answer() == null || doh.Answer().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String expected = TXT_PREFIX + expectedToken;
|
||||
return doh.Answer().stream()
|
||||
.map(DohRecord::data)
|
||||
.filter(d -> d != null)
|
||||
.map(d -> d.startsWith("\"") && d.endsWith("\"") ? d.substring(1, d.length() - 1) : d)
|
||||
.anyMatch(d -> d.equals(expected));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("DoH query failed for _apix-rotation." + domain + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package org.botstandards.apix.verification;
|
||||
|
||||
public record VerificationConfig(
|
||||
String dohUrl,
|
||||
String gleifApiUrl,
|
||||
String openCorporatesApiKey,
|
||||
String openCorporatesApiUrl,
|
||||
String securityTxtUrlTemplate,
|
||||
long httpTimeoutMs
|
||||
) {}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package org.botstandards.apix.verification;
|
||||
|
||||
import org.botstandards.apix.common.OLevel;
|
||||
import org.botstandards.apix.common.VerificationResult;
|
||||
|
||||
public class VerificationPipeline {
|
||||
|
||||
private final O1DnsVerifier o1;
|
||||
private final O2GleifVerifier o2Gleif;
|
||||
private final O2OpenCorporatesVerifier o2Oc;
|
||||
private final O3HygieneVerifier o3;
|
||||
|
||||
public VerificationPipeline(VerificationConfig config) {
|
||||
this.o1 = new O1DnsVerifier(config);
|
||||
this.o2Gleif = new O2GleifVerifier(config);
|
||||
this.o2Oc = new O2OpenCorporatesVerifier(config);
|
||||
this.o3 = new O3HygieneVerifier(config);
|
||||
}
|
||||
|
||||
public VerificationResult run(OLevel targetLevel, String domain, String dnsToken,
|
||||
String legalName, String jurisdiction) {
|
||||
if (targetLevel == OLevel.UNVERIFIED) {
|
||||
return VerificationResult.success(OLevel.UNVERIFIED);
|
||||
}
|
||||
|
||||
if (targetLevel == OLevel.OPERATIONALLY_VERIFIED || targetLevel == OLevel.AUDITED) {
|
||||
return VerificationResult.failure(OLevel.UNVERIFIED, "MANUAL_REVIEW",
|
||||
"requires BSF manual review");
|
||||
}
|
||||
|
||||
VerificationResult o1Result = o1.verify(domain, dnsToken);
|
||||
if (!o1Result.succeeded()) {
|
||||
return o1Result;
|
||||
}
|
||||
if (targetLevel == OLevel.IDENTITY_VERIFIED) {
|
||||
return o1Result;
|
||||
}
|
||||
|
||||
VerificationResult o2Result = o2Gleif.verify(legalName, jurisdiction);
|
||||
if (!o2Result.succeeded()) {
|
||||
o2Result = o2Oc.verify(legalName, jurisdiction);
|
||||
}
|
||||
if (!o2Result.succeeded()) {
|
||||
return VerificationResult.failure(OLevel.IDENTITY_VERIFIED,
|
||||
o2Result.blockedAtStep(), o2Result.message());
|
||||
}
|
||||
if (targetLevel == OLevel.LEGAL_ENTITY_VERIFIED) {
|
||||
return o2Result;
|
||||
}
|
||||
|
||||
return o3.verify(domain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
www.api-index.org {
|
||||
reverse_proxy portal-a:8081 portal-b:8081 {
|
||||
lb_policy first
|
||||
health_uri /q/health/live
|
||||
health_interval 5s
|
||||
fail_duration 30s
|
||||
}
|
||||
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
}
|
||||
|
||||
api-index.org {
|
||||
reverse_proxy registry-a:8180 registry-b:8180 {
|
||||
lb_policy first
|
||||
health_uri /q/health/live
|
||||
health_interval 5s
|
||||
fail_duration 30s
|
||||
}
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
-Server
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/api-index.log
|
||||
format json
|
||||
}
|
||||
}
|
||||
|
||||
demo.api-index.org {
|
||||
reverse_proxy demo-a:8083 demo-b:8083 {
|
||||
lb_policy first
|
||||
health_uri /q/health/live
|
||||
health_interval 5s
|
||||
fail_duration 30s
|
||||
}
|
||||
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
header X-Content-Type-Options "nosniff"
|
||||
header -Server
|
||||
}
|
||||
|
||||
git.api-index.org {
|
||||
reverse_proxy gitea:3001
|
||||
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
header -Server
|
||||
}
|
||||
|
||||
# grafana.api-index.org — access via SSH tunnel for now:
|
||||
# ssh -L 3000:localhost:3000 deploy@204.168.156.179
|
||||
# Uncomment when DNS record is added and bcrypt hash is generated:
|
||||
# caddy hash-password --plaintext <password>
|
||||
# grafana.api-index.org {
|
||||
# basic_auth {
|
||||
# admin $2a$14$REPLACE_WITH_BCRYPT_HASH
|
||||
# }
|
||||
# reverse_proxy grafana:3000
|
||||
# header Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
# }
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
RUN addgroup -S apix && adduser -S apix -G apix
|
||||
WORKDIR /app
|
||||
COPY apix-demo/target/quarkus-app/ quarkus-app/
|
||||
RUN chown -R apix:apix /app
|
||||
USER apix
|
||||
EXPOSE 8083
|
||||
ENTRYPOINT ["java", "-jar", "quarkus-app/quarkus-run.jar"]
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
RUN addgroup -S apix && adduser -S apix -G apix
|
||||
WORKDIR /app
|
||||
COPY apix-portal/target/quarkus-app/ quarkus-app/
|
||||
RUN chown -R apix:apix /app
|
||||
USER apix
|
||||
EXPOSE 8081
|
||||
ENTRYPOINT ["java", "-jar", "quarkus-app/quarkus-run.jar"]
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
RUN addgroup -S apix && adduser -S apix -G apix
|
||||
WORKDIR /app
|
||||
COPY apix-registry/target/quarkus-app/ quarkus-app/
|
||||
RUN chown -R apix:apix /app
|
||||
USER apix
|
||||
EXPOSE 8180
|
||||
ENTRYPOINT ["java", "-jar", "quarkus-app/quarkus-run.jar"]
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
RUN addgroup -S apix && adduser -S apix -G apix
|
||||
WORKDIR /app
|
||||
COPY apix-spider/target/quarkus-app/ quarkus-app/
|
||||
RUN chown -R apix:apix /app
|
||||
USER apix
|
||||
EXPOSE 8082
|
||||
ENTRYPOINT ["java", "-jar", "quarkus-app/quarkus-run.jar"]
|
||||
+183
-19
@@ -1,8 +1,3 @@
|
||||
version: "3.9"
|
||||
|
||||
# Production service topology. For local JVM dev mode see docker-compose.override.yml (Block 5 / I-02).
|
||||
# Images are built and pushed by CI (Block 5 / I-21); Dockerfiles are Block 5-6 (I-04 to I-06).
|
||||
|
||||
services:
|
||||
|
||||
db:
|
||||
@@ -12,7 +7,7 @@ services:
|
||||
POSTGRES_PASSWORD: ${APIX_DB_PASSWORD:-apix}
|
||||
POSTGRES_DB: ${APIX_DB_NAME:-apix}
|
||||
ports:
|
||||
- "${APIX_DB_PORT:-5432}:5432"
|
||||
- "127.0.0.1:${APIX_DB_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -22,7 +17,9 @@ services:
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
registry:
|
||||
# ── Registry (a/b for rolling zero-downtime deploys) ──────────────────────
|
||||
|
||||
registry-a:
|
||||
image: apix-registry:latest
|
||||
ports:
|
||||
- "8180:8180"
|
||||
@@ -31,8 +28,14 @@ services:
|
||||
QUARKUS_DATASOURCE_USERNAME: ${APIX_DB_USER:-apix}
|
||||
QUARKUS_DATASOURCE_PASSWORD: ${APIX_DB_PASSWORD:-apix}
|
||||
APIX_API_KEY: ${APIX_API_KEY}
|
||||
APIX_REGISTRY_BASE_URL: ${APIX_REGISTRY_BASE_URL:-https://api-index.org}
|
||||
APIX_REGISTRY_NAME: ${APIX_REGISTRY_NAME:-APIX Registry}
|
||||
GLEIF_API_URL: ${GLEIF_API_URL:-https://api.gleif.org/api/v1}
|
||||
OPENCORPORATES_API_KEY: ${OPENCORPORATES_API_KEY:-}
|
||||
APIX_MAIL_SIGNING_PRIVATE_KEY: ${APIX_MAIL_SIGNING_PRIVATE_KEY:-}
|
||||
APIX_MAIL_SIGNING_PUBLIC_KEY: ${APIX_MAIL_SIGNING_PUBLIC_KEY:-}
|
||||
APIX_MAIL_SIGNING_KID: ${APIX_MAIL_SIGNING_KID:-dev}
|
||||
APIX_PORTAL_BASE_URL: ${APIX_PORTAL_BASE_URL:-https://www.api-index.org}
|
||||
SANCTIONS_CACHE_PATH: /app/sanctions
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
volumes:
|
||||
@@ -41,13 +44,43 @@ services:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:8180/q/health/live || exit 1"]
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8180/q/health/live || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
# Internal only — no public port exposure
|
||||
registry-b:
|
||||
image: apix-registry:latest
|
||||
environment:
|
||||
QUARKUS_DATASOURCE_JDBC_URL: jdbc:postgresql://db:5432/${APIX_DB_NAME:-apix}
|
||||
QUARKUS_DATASOURCE_USERNAME: ${APIX_DB_USER:-apix}
|
||||
QUARKUS_DATASOURCE_PASSWORD: ${APIX_DB_PASSWORD:-apix}
|
||||
APIX_API_KEY: ${APIX_API_KEY}
|
||||
APIX_REGISTRY_BASE_URL: ${APIX_REGISTRY_BASE_URL:-https://api-index.org}
|
||||
APIX_REGISTRY_NAME: ${APIX_REGISTRY_NAME:-APIX Registry}
|
||||
GLEIF_API_URL: ${GLEIF_API_URL:-https://api.gleif.org/api/v1}
|
||||
OPENCORPORATES_API_KEY: ${OPENCORPORATES_API_KEY:-}
|
||||
APIX_MAIL_SIGNING_PRIVATE_KEY: ${APIX_MAIL_SIGNING_PRIVATE_KEY:-}
|
||||
APIX_MAIL_SIGNING_PUBLIC_KEY: ${APIX_MAIL_SIGNING_PUBLIC_KEY:-}
|
||||
APIX_MAIL_SIGNING_KID: ${APIX_MAIL_SIGNING_KID:-dev}
|
||||
APIX_PORTAL_BASE_URL: ${APIX_PORTAL_BASE_URL:-https://www.api-index.org}
|
||||
SANCTIONS_CACHE_PATH: /app/sanctions
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
volumes:
|
||||
- sanctions_cache:/app/sanctions
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8180/q/health/live || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Spider (single — cron job, no in-flight request concern) ─────────────
|
||||
|
||||
spider:
|
||||
image: apix-spider:latest
|
||||
environment:
|
||||
@@ -60,28 +93,88 @@ services:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:8082/q/health/live || exit 1"]
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8082/q/health/live || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
portal:
|
||||
image: apix-portal:latest
|
||||
ports:
|
||||
- "8081:8081"
|
||||
# ── Demo (a/b) ────────────────────────────────────────────────────────────
|
||||
|
||||
demo-a:
|
||||
image: apix-demo:latest
|
||||
environment:
|
||||
REGISTRY_BASE_URL: http://registry:8180
|
||||
QUARKUS_DATASOURCE_JDBC_URL: jdbc:postgresql://db:5432/${APIX_DB_NAME:-apix}
|
||||
QUARKUS_DATASOURCE_USERNAME: ${APIX_DB_USER:-apix}
|
||||
QUARKUS_DATASOURCE_PASSWORD: ${APIX_DB_PASSWORD:-apix}
|
||||
APIX_REGISTRY_URL: http://registry-a:8180
|
||||
APIX_API_KEY: ${APIX_API_KEY}
|
||||
APIX_DEMO_BASE_URL: ${APIX_DEMO_BASE_URL:-https://demo.api-index.org}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
depends_on:
|
||||
- registry
|
||||
registry-a:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:8081/q/health/live || exit 1"]
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8083/q/health/live || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
demo-b:
|
||||
image: apix-demo:latest
|
||||
environment:
|
||||
QUARKUS_DATASOURCE_JDBC_URL: jdbc:postgresql://db:5432/${APIX_DB_NAME:-apix}
|
||||
QUARKUS_DATASOURCE_USERNAME: ${APIX_DB_USER:-apix}
|
||||
QUARKUS_DATASOURCE_PASSWORD: ${APIX_DB_PASSWORD:-apix}
|
||||
APIX_REGISTRY_URL: http://registry-b:8180
|
||||
APIX_API_KEY: ${APIX_API_KEY}
|
||||
APIX_DEMO_BASE_URL: ${APIX_DEMO_BASE_URL:-https://demo.api-index.org}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
depends_on:
|
||||
registry-b:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8083/q/health/live || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Portal (a/b) ──────────────────────────────────────────────────────────
|
||||
|
||||
portal-a:
|
||||
image: apix-portal:latest
|
||||
ports:
|
||||
- "8081:8081"
|
||||
environment:
|
||||
APIX_REGISTRY_URL: http://registry-a:8180
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
depends_on:
|
||||
- registry-a
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/q/health/live || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
portal-b:
|
||||
image: apix-portal:latest
|
||||
environment:
|
||||
APIX_REGISTRY_URL: http://registry-b:8180
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
depends_on:
|
||||
- registry-b
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/q/health/live || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Edge proxy ────────────────────────────────────────────────────────────
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
ports:
|
||||
@@ -93,8 +186,76 @@ services:
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
depends_on:
|
||||
- registry
|
||||
- portal
|
||||
- registry-a
|
||||
- portal-a
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Source control & CI ───────────────────────────────────────────────────
|
||||
|
||||
gitea:
|
||||
image: gitea/gitea:1
|
||||
environment:
|
||||
USER_UID: "1000"
|
||||
USER_GID: "1000"
|
||||
GITEA__server__DOMAIN: git.api-index.org
|
||||
GITEA__server__ROOT_URL: https://git.api-index.org
|
||||
GITEA__server__HTTP_PORT: "3001"
|
||||
GITEA__server__SSH_PORT: "2222"
|
||||
GITEA__server__SSH_DOMAIN: git.api-index.org
|
||||
GITEA__database__DB_TYPE: sqlite3
|
||||
GITEA__security__SECRET_KEY: ${GITEA_SECRET_KEY}
|
||||
GITEA__security__INTERNAL_TOKEN: ${GITEA_INTERNAL_TOKEN}
|
||||
GITEA__security__INSTALL_LOCK: "true"
|
||||
GITEA__service__DISABLE_REGISTRATION: "true"
|
||||
GITEA__service__REQUIRE_SIGNIN_VIEW: "false"
|
||||
GITEA__actions__ENABLED: "true"
|
||||
GITEA__log__LEVEL: warn
|
||||
ports:
|
||||
- "127.0.0.1:3001:3001"
|
||||
- "2222:2222"
|
||||
volumes:
|
||||
- gitea_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Observability ─────────────────────────────────────────────────────────
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.53.1
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- prometheus_data:/prometheus
|
||||
command:
|
||||
- --config.file=/etc/prometheus/prometheus.yml
|
||||
- --storage.tsdb.path=/prometheus
|
||||
- --storage.tsdb.retention.time=30d
|
||||
- --web.enable-lifecycle
|
||||
ports:
|
||||
- "127.0.0.1:9090:9090"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:9090/-/healthy || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:11.1.3
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-http://localhost:3000}
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
depends_on:
|
||||
- prometheus
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
@@ -102,3 +263,6 @@ volumes:
|
||||
sanctions_cache:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
prometheus_data:
|
||||
grafana_data:
|
||||
gitea_data:
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
{
|
||||
"uid": "apix-registry-perf",
|
||||
"title": "APIX Registry — Performance",
|
||||
"tags": ["apix", "registry"],
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"refresh": "30s",
|
||||
"time": { "from": "now-1h", "to": "now" },
|
||||
"templating": { "list": [] },
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Request rate",
|
||||
"type": "stat",
|
||||
"gridPos": { "x": 0, "y": 0, "w": 6, "h": 4 },
|
||||
"targets": [
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "sum(rate(http_server_requests_seconds_count{job=\"apix-registry\"}[5m]))",
|
||||
"instant": true,
|
||||
"legendFormat": "req/s"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "reqps",
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 50 },
|
||||
{ "color": "red", "value": 200 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"] },
|
||||
"colorMode": "background",
|
||||
"graphMode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "P50 latency",
|
||||
"type": "stat",
|
||||
"gridPos": { "x": 6, "y": 0, "w": 6, "h": 4 },
|
||||
"targets": [
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "histogram_quantile(0.50, sum by (le) (rate(http_server_requests_seconds_bucket{job=\"apix-registry\"}[5m]))) * 1000",
|
||||
"instant": true,
|
||||
"legendFormat": "P50 ms"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "ms",
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 100 },
|
||||
{ "color": "red", "value": 500 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"] },
|
||||
"colorMode": "background",
|
||||
"graphMode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "P95 latency",
|
||||
"type": "stat",
|
||||
"gridPos": { "x": 12, "y": 0, "w": 6, "h": 4 },
|
||||
"targets": [
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{job=\"apix-registry\"}[5m]))) * 1000",
|
||||
"instant": true,
|
||||
"legendFormat": "P95 ms"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "ms",
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 200 },
|
||||
{ "color": "red", "value": 1000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"] },
|
||||
"colorMode": "background",
|
||||
"graphMode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Error rate",
|
||||
"type": "stat",
|
||||
"gridPos": { "x": 18, "y": 0, "w": 6, "h": 4 },
|
||||
"targets": [
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "100 * sum(rate(http_server_requests_seconds_count{job=\"apix-registry\",outcome=\"SERVER_ERROR\"}[5m])) / sum(rate(http_server_requests_seconds_count{job=\"apix-registry\"}[5m]))",
|
||||
"instant": true,
|
||||
"legendFormat": "error %"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 1 },
|
||||
{ "color": "red", "value": 5 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"] },
|
||||
"colorMode": "background",
|
||||
"graphMode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Latency by endpoint — P50 / P95 (ms)",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "x": 0, "y": 4, "w": 12, "h": 9 },
|
||||
"targets": [
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "histogram_quantile(0.50, sum by (le, uri) (rate(http_server_requests_seconds_bucket{job=\"apix-registry\"}[5m]))) * 1000",
|
||||
"legendFormat": "P50 {{uri}}"
|
||||
},
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "histogram_quantile(0.95, sum by (le, uri) (rate(http_server_requests_seconds_bucket{job=\"apix-registry\"}[5m]))) * 1000",
|
||||
"legendFormat": "P95 {{uri}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "ms" },
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" },
|
||||
"tooltip": { "mode": "multi" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "Request rate by endpoint (req/s)",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "x": 12, "y": 4, "w": 12, "h": 9 },
|
||||
"targets": [
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "sum by (method, uri) (rate(http_server_requests_seconds_count{job=\"apix-registry\"}[5m]))",
|
||||
"legendFormat": "{{method}} {{uri}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "reqps" },
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" },
|
||||
"tooltip": { "mode": "multi" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title": "HTTP status code distribution",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "x": 0, "y": 13, "w": 12, "h": 8 },
|
||||
"targets": [
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "sum by (status) (rate(http_server_requests_seconds_count{job=\"apix-registry\"}[5m]))",
|
||||
"legendFormat": "HTTP {{status}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "reqps" },
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": { "id": "byRegexp", "options": "HTTP 4.." },
|
||||
"properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byRegexp", "options": "HTTP 5.." },
|
||||
"properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }]
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean"], "displayMode": "table", "placement": "bottom" },
|
||||
"tooltip": { "mode": "multi" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"title": "IoT replacements endpoint — P95 latency (ms)",
|
||||
"description": "Focused view on GET /services/{id}/replacements — the hot path for IoT device discovery.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "x": 12, "y": 13, "w": 12, "h": 8 },
|
||||
"targets": [
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{job=\"apix-registry\",uri=\"/services/{id}/replacements\"}[5m]))) * 1000",
|
||||
"legendFormat": "P95 /replacements"
|
||||
},
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "histogram_quantile(0.50, sum by (le) (rate(http_server_requests_seconds_bucket{job=\"apix-registry\",uri=\"/services/{id}/replacements\"}[5m]))) * 1000",
|
||||
"legendFormat": "P50 /replacements"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "ms" },
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" },
|
||||
"tooltip": { "mode": "multi" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: APIX
|
||||
type: file
|
||||
disableDeletion: true
|
||||
updateIntervalSeconds: 30
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
@@ -0,0 +1,25 @@
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: apix-registry
|
||||
metrics_path: /q/metrics
|
||||
static_configs:
|
||||
- targets: ['registry:8180']
|
||||
labels:
|
||||
service: registry
|
||||
|
||||
- job_name: apix-spider
|
||||
metrics_path: /q/metrics
|
||||
static_configs:
|
||||
- targets: ['spider:8082']
|
||||
labels:
|
||||
service: spider
|
||||
|
||||
- job_name: apix-portal
|
||||
metrics_path: /q/metrics
|
||||
static_configs:
|
||||
- targets: ['portal:8081']
|
||||
labels:
|
||||
service: portal
|
||||
@@ -0,0 +1,60 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
$VPS = "deploy@204.168.156.179"
|
||||
$MVN = "C:\Users\Anwender\IdeaProjects\Claude\bot-service-index\apix-mvp"
|
||||
|
||||
Set-Location $MVN
|
||||
|
||||
Write-Host "Copying web content into portal..."
|
||||
Copy-Item "$MVN\..\web\api-index.org\index.html" `
|
||||
"$MVN\apix-portal\src\main\resources\META-INF\resources\index.html" -Force
|
||||
|
||||
Write-Host "Building all modules..."
|
||||
mvn clean package -DskipTests -q
|
||||
|
||||
Write-Host "Transferring infra config..."
|
||||
scp "$MVN\infra\Caddyfile" "${VPS}:/opt/apix/infra/Caddyfile"
|
||||
scp "$MVN\infra\docker-compose.yml" "${VPS}:/opt/apix/infra/docker-compose.yml"
|
||||
scp "$MVN\infra\Dockerfile.registry" "${VPS}:/opt/apix/infra/Dockerfile.registry"
|
||||
scp "$MVN\infra\Dockerfile.portal" "${VPS}:/opt/apix/infra/Dockerfile.portal"
|
||||
scp "$MVN\infra\Dockerfile.spider" "${VPS}:/opt/apix/infra/Dockerfile.spider"
|
||||
scp "$MVN\infra\Dockerfile.demo" "${VPS}:/opt/apix/infra/Dockerfile.demo"
|
||||
|
||||
Write-Host "Transferring registry..."
|
||||
scp "$MVN\apix-registry\target\quarkus-app\app\apix-registry-1.0-SNAPSHOT.jar" `
|
||||
"${VPS}:/opt/apix/apix-registry/target/quarkus-app/app/apix-registry-1.0-SNAPSHOT.jar"
|
||||
scp "$MVN\apix-registry\target\quarkus-app\quarkus\quarkus-application.dat" `
|
||||
"${VPS}:/opt/apix/apix-registry/target/quarkus-app/quarkus/quarkus-application.dat"
|
||||
|
||||
Write-Host "Transferring portal..."
|
||||
scp "$MVN\apix-portal\target\quarkus-app\app\apix-portal-1.0-SNAPSHOT.jar" `
|
||||
"${VPS}:/opt/apix/apix-portal/target/quarkus-app/app/apix-portal-1.0-SNAPSHOT.jar"
|
||||
scp "$MVN\apix-portal\target\quarkus-app\quarkus\quarkus-application.dat" `
|
||||
"${VPS}:/opt/apix/apix-portal/target/quarkus-app/quarkus/quarkus-application.dat"
|
||||
|
||||
# Spider + Demo are small — full copy every time so lib/ is always current
|
||||
Write-Host "Transferring spider (full copy)..."
|
||||
ssh $VPS "mkdir -p /opt/apix/apix-spider/target"
|
||||
scp -r "$MVN\apix-spider\target\quarkus-app" `
|
||||
"${VPS}:/opt/apix/apix-spider/target/quarkus-app"
|
||||
|
||||
Write-Host "Transferring demo (full copy)..."
|
||||
ssh $VPS "mkdir -p /opt/apix/apix-demo/target"
|
||||
scp -r "$MVN\apix-demo\target\quarkus-app" `
|
||||
"${VPS}:/opt/apix/apix-demo/target/quarkus-app"
|
||||
|
||||
Write-Host "Rebuilding images and restarting stack..."
|
||||
ssh $VPS @"
|
||||
cd /opt/apix && \
|
||||
docker build -f infra/Dockerfile.registry -t apix-registry:latest . -q && \
|
||||
docker build -f infra/Dockerfile.portal -t apix-portal:latest . -q && \
|
||||
docker build -f infra/Dockerfile.spider -t apix-spider:latest . -q && \
|
||||
docker build -f infra/Dockerfile.demo -t apix-demo:latest . -q && \
|
||||
docker compose -f infra/docker-compose.yml --env-file .env up -d registry portal spider demo && \
|
||||
docker exec infra-caddy-1 caddy reload --config /etc/caddy/Caddyfile
|
||||
"@
|
||||
|
||||
Write-Host "Done."
|
||||
Write-Host " Registry: https://api-index.org/"
|
||||
Write-Host " Portal: https://www.api-index.org/"
|
||||
Write-Host " Demo: https://demo.api-index.org/"
|
||||
Write-Host " Spider: internal (no public port)"
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/bin/bash
|
||||
# Rolling zero-downtime deploy for APIX.
|
||||
# Triggered by Gitea Actions (act_runner, host process) on push to main.
|
||||
# Requires: Docker on the runner host. Maven runs inside a container.
|
||||
#
|
||||
# Strategy: a/b container pairs per service. Caddy health-routes between them.
|
||||
# Deploy restarts a-stack then b-stack sequentially — one instance always healthy.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INFRA_DIR="/opt/apix/infra"
|
||||
ENV_FILE="/opt/apix/.env"
|
||||
COMPOSE="docker compose -f $INFRA_DIR/docker-compose.yml --env-file $ENV_FILE"
|
||||
|
||||
log() { echo "[deploy $(date -u +%H:%M:%S)] $*"; }
|
||||
|
||||
wait_healthy() {
|
||||
local service=$1
|
||||
local container="infra-${service}-1"
|
||||
local max=90
|
||||
local elapsed=0
|
||||
log "Waiting for $service to become healthy..."
|
||||
while [ $elapsed -lt $max ]; do
|
||||
status=$(docker inspect "$container" \
|
||||
--format '{{.State.Health.Status}}' 2>/dev/null || echo "missing")
|
||||
if [ "$status" = "healthy" ]; then
|
||||
log "$service is healthy."
|
||||
return 0
|
||||
fi
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
done
|
||||
log "ERROR: $service did not become healthy after ${max}s (last status: $status)"
|
||||
docker logs "$container" --tail 20 2>&1 || true
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── 1. Sync infra config to /opt/apix/infra ──────────────────────────────────
|
||||
log "Syncing infra config..."
|
||||
cp infra/Caddyfile "$INFRA_DIR/Caddyfile"
|
||||
cp infra/docker-compose.yml "$INFRA_DIR/docker-compose.yml"
|
||||
cp infra/Dockerfile.* "$INFRA_DIR/"
|
||||
[ -f infra/prometheus.yml ] && cp infra/prometheus.yml "$INFRA_DIR/"
|
||||
|
||||
# ── 2. Build JARs (Maven runs in Docker — no JDK/Maven required on host) ──────
|
||||
log "Building JARs..."
|
||||
# GITHUB_WORKSPACE is set by act_runner; work dir is mounted at the same host path.
|
||||
BUILD_ROOT="${GITHUB_WORKSPACE:-$(pwd)}"
|
||||
docker run --rm \
|
||||
-v "${BUILD_ROOT}:/workspace" \
|
||||
-v "/home/deploy/gitea-runner/.m2:/root/.m2" \
|
||||
-w /workspace \
|
||||
maven:3.9-eclipse-temurin-21 \
|
||||
mvn clean package -DskipTests -q
|
||||
|
||||
# ── 3. Build Docker images ────────────────────────────────────────────────────
|
||||
log "Building Docker images..."
|
||||
docker build -f infra/Dockerfile.registry -t apix-registry:latest . -q
|
||||
docker build -f infra/Dockerfile.portal -t apix-portal:latest . -q
|
||||
docker build -f infra/Dockerfile.demo -t apix-demo:latest . -q
|
||||
docker build -f infra/Dockerfile.spider -t apix-spider:latest . -q
|
||||
|
||||
# ── 4. Rolling restart: a-stack (registry-a → portal-a + demo-a) ─────────────
|
||||
log "Rolling restart: a-stack..."
|
||||
$COMPOSE up -d --no-deps --force-recreate registry-a
|
||||
wait_healthy "registry-a"
|
||||
|
||||
$COMPOSE up -d --no-deps --force-recreate portal-a demo-a
|
||||
wait_healthy "portal-a"
|
||||
wait_healthy "demo-a"
|
||||
|
||||
# ── 5. Rolling restart: b-stack ───────────────────────────────────────────────
|
||||
log "Rolling restart: b-stack..."
|
||||
$COMPOSE up -d --no-deps --force-recreate registry-b
|
||||
wait_healthy "registry-b"
|
||||
|
||||
$COMPOSE up -d --no-deps --force-recreate portal-b demo-b
|
||||
wait_healthy "portal-b"
|
||||
wait_healthy "demo-b"
|
||||
|
||||
# ── 6. Spider (cron job — restart acceptable) ─────────────────────────────────
|
||||
log "Restarting spider..."
|
||||
$COMPOSE up -d --no-deps --force-recreate spider
|
||||
|
||||
# ── 7. Reload Caddy ───────────────────────────────────────────────────────────
|
||||
log "Reloading Caddy..."
|
||||
docker exec infra-caddy-1 caddy reload --config /etc/caddy/Caddyfile
|
||||
|
||||
log "Deploy complete. All services updated with zero downtime."
|
||||
Reference in New Issue
Block a user