diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..f2c493d --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/apix-common/src/main/java/org/botstandards/apix/common/OrgEventType.java b/apix-common/src/main/java/org/botstandards/apix/common/OrgEventType.java new file mode 100644 index 0000000..60b5650 --- /dev/null +++ b/apix-common/src/main/java/org/botstandards/apix/common/OrgEventType.java @@ -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 +} diff --git a/apix-common/src/main/java/org/botstandards/apix/common/VerificationResult.java b/apix-common/src/main/java/org/botstandards/apix/common/VerificationResult.java index ea5c858..c6f8c60 100644 --- a/apix-common/src/main/java/org/botstandards/apix/common/VerificationResult.java +++ b/apix-common/src/main/java/org/botstandards/apix/common/VerificationResult.java @@ -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; + } +} diff --git a/apix-common/src/main/java/org/botstandards/apix/common/VerificationStatus.java b/apix-common/src/main/java/org/botstandards/apix/common/VerificationStatus.java new file mode 100644 index 0000000..bedeb1d --- /dev/null +++ b/apix-common/src/main/java/org/botstandards/apix/common/VerificationStatus.java @@ -0,0 +1,10 @@ +package org.botstandards.apix.common; + +public enum VerificationStatus { + PENDING, + VERIFYING, + ACHIEVED, + FAILED, + MANUAL_REVIEW, + SUSPENDED +} diff --git a/apix-registry/pom.xml b/apix-registry/pom.xml index c59ed2c..86a142e 100644 --- a/apix-registry/pom.xml +++ b/apix-registry/pom.xml @@ -55,6 +55,12 @@ quarkus-logging-json + + + io.quarkus + quarkus-micrometer-registry-prometheus + + io.quarkus @@ -118,6 +124,63 @@ + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-integration-test-source + generate-test-sources + add-test-source + + + src/integration-test/java + + + + + add-integration-test-resource + generate-test-resources + add-test-resource + + + + src/integration-test/resources + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*IT.java + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + + + **/*IT.java + + + ${quarkus.platform.group-id} quarkus-maven-plugin diff --git a/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotProfileCucumberTest.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotProfileCucumberIT.java similarity index 86% rename from apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotProfileCucumberTest.java rename to apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotProfileCucumberIT.java index 8b482ca..ee837d8 100644 --- a/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotProfileCucumberTest.java +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotProfileCucumberIT.java @@ -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() { diff --git a/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotTransitionCucumberTest.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionCucumberIT.java similarity index 96% rename from apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotTransitionCucumberTest.java rename to apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionCucumberIT.java index aa6f8fc..b366745 100644 --- a/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotTransitionCucumberTest.java +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionCucumberIT.java @@ -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() { diff --git a/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java similarity index 94% rename from apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java rename to apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java index 5273ed2..a3e5412 100644 --- a/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java @@ -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 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)); + } } diff --git a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/OrgOnboardingCucumberIT.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/OrgOnboardingCucumberIT.java new file mode 100644 index 0000000..a69a67d --- /dev/null +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/OrgOnboardingCucumberIT.java @@ -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"); + } +} diff --git a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/OrgOnboardingSteps.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/OrgOnboardingSteps.java new file mode 100644 index 0000000..8d5137f --- /dev/null +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/OrgOnboardingSteps.java @@ -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> 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> 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> 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 buildRegistrationBody(String name, String email, + String jurisdiction, String orgType, + String domain, String targetLevel) { + Map 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))); + } +} diff --git a/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/TestSetup.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/TestSetup.java similarity index 92% rename from apix-registry/src/test/java/org/botstandards/apix/registry/bdd/TestSetup.java rename to apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/TestSetup.java index 3b7bcb9..938b9bf 100644 --- a/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/TestSetup.java +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/TestSetup.java @@ -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"); } } diff --git a/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/TestTimeResource.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/TestTimeResource.java similarity index 100% rename from apix-registry/src/test/java/org/botstandards/apix/registry/bdd/TestTimeResource.java rename to apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/TestTimeResource.java diff --git a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/WireMockSetup.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/WireMockSetup.java new file mode 100644 index 0000000..535ab81 --- /dev/null +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/WireMockSetup.java @@ -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; + } +} diff --git a/apix-registry/src/test/resources/allure.properties b/apix-registry/src/integration-test/resources/allure.properties similarity index 100% rename from apix-registry/src/test/resources/allure.properties rename to apix-registry/src/integration-test/resources/allure.properties diff --git a/apix-registry/src/test/resources/application.properties b/apix-registry/src/integration-test/resources/application.properties similarity index 63% rename from apix-registry/src/test/resources/application.properties rename to apix-registry/src/integration-test/resources/application.properties index 5e09f00..c493ec1 100644 --- a/apix-registry/src/test/resources/application.properties +++ b/apix-registry/src/integration-test/resources/application.properties @@ -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 diff --git a/apix-registry/src/test/resources/features/iot-profile/iot-profile.feature b/apix-registry/src/integration-test/resources/features/iot-profile/iot-profile.feature similarity index 58% rename from apix-registry/src/test/resources/features/iot-profile/iot-profile.feature rename to apix-registry/src/integration-test/resources/features/iot-profile/iot-profile.feature index 2e3864d..f660693 100644 --- a/apix-registry/src/test/resources/features/iot-profile/iot-profile.feature +++ b/apix-registry/src/integration-test/resources/features/iot-profile/iot-profile.feature @@ -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 diff --git a/apix-registry/src/test/resources/features/iot-transition/anonymity.feature b/apix-registry/src/integration-test/resources/features/iot-transition/anonymity.feature similarity index 100% rename from apix-registry/src/test/resources/features/iot-transition/anonymity.feature rename to apix-registry/src/integration-test/resources/features/iot-transition/anonymity.feature diff --git a/apix-registry/src/test/resources/features/iot-transition/decommissioning.feature b/apix-registry/src/integration-test/resources/features/iot-transition/decommissioning.feature similarity index 100% rename from apix-registry/src/test/resources/features/iot-transition/decommissioning.feature rename to apix-registry/src/integration-test/resources/features/iot-transition/decommissioning.feature diff --git a/apix-registry/src/test/resources/features/iot-transition/replacement-declaration.feature b/apix-registry/src/integration-test/resources/features/iot-transition/replacement-declaration.feature similarity index 100% rename from apix-registry/src/test/resources/features/iot-transition/replacement-declaration.feature rename to apix-registry/src/integration-test/resources/features/iot-transition/replacement-declaration.feature diff --git a/apix-registry/src/test/resources/features/iot-transition/replacement-discovery.feature b/apix-registry/src/integration-test/resources/features/iot-transition/replacement-discovery.feature similarity index 100% rename from apix-registry/src/test/resources/features/iot-transition/replacement-discovery.feature rename to apix-registry/src/integration-test/resources/features/iot-transition/replacement-discovery.feature diff --git a/apix-registry/src/test/resources/features/iot-transition/sunset-and-lock.feature b/apix-registry/src/integration-test/resources/features/iot-transition/sunset-and-lock.feature similarity index 100% rename from apix-registry/src/test/resources/features/iot-transition/sunset-and-lock.feature rename to apix-registry/src/integration-test/resources/features/iot-transition/sunset-and-lock.feature diff --git a/apix-registry/src/integration-test/resources/features/org-onboarding/org-audit-log.feature b/apix-registry/src/integration-test/resources/features/org-onboarding/org-audit-log.feature new file mode 100644 index 0000000..724c8df --- /dev/null +++ b/apix-registry/src/integration-test/resources/features/org-onboarding/org-audit-log.feature @@ -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" diff --git a/apix-registry/src/integration-test/resources/features/org-onboarding/org-bsf-grants.feature b/apix-registry/src/integration-test/resources/features/org-onboarding/org-bsf-grants.feature new file mode 100644 index 0000000..d7396f2 --- /dev/null +++ b/apix-registry/src/integration-test/resources/features/org-onboarding/org-bsf-grants.feature @@ -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 diff --git a/apix-registry/src/integration-test/resources/features/org-onboarding/org-key-rotation-dns.feature b/apix-registry/src/integration-test/resources/features/org-onboarding/org-key-rotation-dns.feature new file mode 100644 index 0000000..d5ed361 --- /dev/null +++ b/apix-registry/src/integration-test/resources/features/org-onboarding/org-key-rotation-dns.feature @@ -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" diff --git a/apix-registry/src/integration-test/resources/features/org-onboarding/org-level-transitions.feature b/apix-registry/src/integration-test/resources/features/org-onboarding/org-level-transitions.feature new file mode 100644 index 0000000..e732187 --- /dev/null +++ b/apix-registry/src/integration-test/resources/features/org-onboarding/org-level-transitions.feature @@ -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 diff --git a/apix-registry/src/integration-test/resources/features/org-onboarding/org-registration.feature b/apix-registry/src/integration-test/resources/features/org-onboarding/org-registration.feature new file mode 100644 index 0000000..8525cfb --- /dev/null +++ b/apix-registry/src/integration-test/resources/features/org-onboarding/org-registration.feature @@ -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 diff --git a/apix-registry/src/integration-test/resources/features/org-onboarding/org-verification-o1.feature b/apix-registry/src/integration-test/resources/features/org-onboarding/org-verification-o1.feature new file mode 100644 index 0000000..f18154c --- /dev/null +++ b/apix-registry/src/integration-test/resources/features/org-onboarding/org-verification-o1.feature @@ -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 diff --git a/apix-registry/src/integration-test/resources/features/org-onboarding/org-verification-o2.feature b/apix-registry/src/integration-test/resources/features/org-onboarding/org-verification-o2.feature new file mode 100644 index 0000000..14b3789 --- /dev/null +++ b/apix-registry/src/integration-test/resources/features/org-onboarding/org-verification-o2.feature @@ -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" diff --git a/apix-registry/src/integration-test/resources/features/org-onboarding/org-verification-o3.feature b/apix-registry/src/integration-test/resources/features/org-onboarding/org-verification-o3.feature new file mode 100644 index 0000000..781d17d --- /dev/null +++ b/apix-registry/src/integration-test/resources/features/org-onboarding/org-verification-o3.feature @@ -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" diff --git a/apix-registry/src/test/resources/junit-platform.properties b/apix-registry/src/integration-test/resources/junit-platform.properties similarity index 70% rename from apix-registry/src/test/resources/junit-platform.properties rename to apix-registry/src/integration-test/resources/junit-platform.properties index c669790..91deb4a 100644 --- a/apix-registry/src/test/resources/junit-platform.properties +++ b/apix-registry/src/integration-test/resources/junit-platform.properties @@ -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_ diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/MailSigningKeyResponse.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/MailSigningKeyResponse.java new file mode 100644 index 0000000..3a7f101 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/MailSigningKeyResponse.java @@ -0,0 +1,26 @@ +package org.botstandards.apix.registry.dto; + +import java.util.List; + +public record MailSigningKeyResponse( + String issuer, + SigningInfo signingInfo, + List keys) { + + public record SigningInfo( + String algorithm, + String curve, + String javaAlgorithmId, + String canonicalization, + String signatureEncoding, + List verificationSteps, + String javaKeyReconstruction) {} + + public record KeyInfo( + String kid, + String kty, + String crv, + String x, + String use, + String publicKeySpkiBase64) {} +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgAuditEventResponse.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgAuditEventResponse.java new file mode 100644 index 0000000..44b5e80 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgAuditEventResponse.java @@ -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); + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgEventFeedResponse.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgEventFeedResponse.java new file mode 100644 index 0000000..3b941d3 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgEventFeedResponse.java @@ -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 events) {} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgFraudReportRequest.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgFraudReportRequest.java new file mode 100644 index 0000000..705382f --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgFraudReportRequest.java @@ -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 +) {} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgKeyRotateResponse.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgKeyRotateResponse.java new file mode 100644 index 0000000..155a3a5 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgKeyRotateResponse.java @@ -0,0 +1,13 @@ +package org.botstandards.apix.registry.dto; + +import java.util.Map; + +public record OrgKeyRotateResponse( + String apiKey, + String rotationSecret, + String warning, + Map signedNotification) { + + public static final String KEY_WARNING = + "New credentials shown exactly once. Store securely — they cannot be retrieved again."; +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgRegistrationRequest.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgRegistrationRequest.java new file mode 100644 index 0000000..6f77b31 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgRegistrationRequest.java @@ -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 +) {} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgRegistrationResponse.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgRegistrationResponse.java new file mode 100644 index 0000000..1f8a58f --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgRegistrationResponse.java @@ -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."; +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgResponse.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgResponse.java new file mode 100644 index 0000000..2e78cab --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgResponse.java @@ -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 + ); + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgTanRequestBody.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgTanRequestBody.java new file mode 100644 index 0000000..236b60b --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgTanRequestBody.java @@ -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) {} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgTanRotateRequest.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgTanRotateRequest.java new file mode 100644 index 0000000..9dbb3f1 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgTanRotateRequest.java @@ -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) {} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgTempGrantRequest.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgTempGrantRequest.java new file mode 100644 index 0000000..19c4ea0 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgTempGrantRequest.java @@ -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 +) {} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgUpgradeRequest.java b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgUpgradeRequest.java new file mode 100644 index 0000000..79079b5 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/dto/OrgUpgradeRequest.java @@ -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) {} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/entity/OrgVerificationEventEntity.java b/apix-registry/src/main/java/org/botstandards/apix/registry/entity/OrgVerificationEventEntity.java new file mode 100644 index 0000000..9f29b17 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/entity/OrgVerificationEventEntity.java @@ -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; +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/entity/OrganizationEntity.java b/apix-registry/src/main/java/org/botstandards/apix/registry/entity/OrganizationEntity.java new file mode 100644 index 0000000..1ea2b16 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/entity/OrganizationEntity.java @@ -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; + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/filter/ServerTimingFilter.java b/apix-registry/src/main/java/org/botstandards/apix/registry/filter/ServerTimingFilter.java new file mode 100644 index 0000000..e7b6dc2 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/filter/ServerTimingFilter.java @@ -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)); + } + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/resource/MailSigningKeyResource.java b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/MailSigningKeyResource.java new file mode 100644 index 0000000..6ab3a63 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/MailSigningKeyResource.java @@ -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() + )) + ); + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/resource/OrganizationResource.java b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/OrganizationResource.java new file mode 100644 index 0000000..293994f --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/OrganizationResource.java @@ -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 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= 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 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(); + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/service/MailSigningService.java b/apix-registry/src/main/java/org/botstandards/apix/registry/service/MailSigningService.java new file mode 100644 index 0000000..57bd1d5 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/service/MailSigningService.java @@ -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 privateKeyBase64; + + @ConfigProperty(name = "apix.mail.signing.public-key-base64") + Optional 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 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 signedNotification(UUID orgId, String eventType, + String timestamp, String message) { + Map 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 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 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 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"); + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/service/OrganizationService.java b/apix-registry/src/main/java/org/botstandards/apix/registry/service/OrganizationService.java new file mode 100644 index 0000000..50d6e5c --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/service/OrganizationService.java @@ -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 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 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 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 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 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 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 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 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 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 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 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); + } + } +} diff --git a/apix-registry/src/main/resources/application.properties b/apix-registry/src/main/resources/application.properties index 213089f..8fdb4dd 100644 --- a/apix-registry/src/main/resources/application.properties +++ b/apix-registry/src/main/resources/application.properties @@ -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} diff --git a/apix-registry/src/main/resources/db/changelog/changes/010-organizations.xml b/apix-registry/src/main/resources/db/changelog/changes/010-organizations.xml new file mode 100644 index 0000000..a63080c --- /dev/null +++ b/apix-registry/src/main/resources/db/changelog/changes/010-organizations.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apix-registry/src/main/resources/db/changelog/changes/011-org-rotation-challenge.xml b/apix-registry/src/main/resources/db/changelog/changes/011-org-rotation-challenge.xml new file mode 100644 index 0000000..75cd4c7 --- /dev/null +++ b/apix-registry/src/main/resources/db/changelog/changes/011-org-rotation-challenge.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + diff --git a/apix-registry/src/main/resources/db/changelog/db.changelog-master.xml b/apix-registry/src/main/resources/db/changelog/db.changelog-master.xml index d43cc32..bf14639 100644 --- a/apix-registry/src/main/resources/db/changelog/db.changelog-master.xml +++ b/apix-registry/src/main/resources/db/changelog/db.changelog-master.xml @@ -14,5 +14,7 @@ + + diff --git a/apix-verification/src/main/java/org/botstandards/apix/verification/O1DnsVerifier.java b/apix-verification/src/main/java/org/botstandards/apix/verification/O1DnsVerifier.java new file mode 100644 index 0000000..2a3aaa7 --- /dev/null +++ b/apix-verification/src/main/java/org/botstandards/apix/verification/O1DnsVerifier.java @@ -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 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 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 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()); + } + } +} diff --git a/apix-verification/src/main/java/org/botstandards/apix/verification/O2GleifVerifier.java b/apix-verification/src/main/java/org/botstandards/apix/verification/O2GleifVerifier.java new file mode 100644 index 0000000..38263f5 --- /dev/null +++ b/apix-verification/src/main/java/org/botstandards/apix/verification/O2GleifVerifier.java @@ -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 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 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()); + } + } +} diff --git a/apix-verification/src/main/java/org/botstandards/apix/verification/O2OpenCorporatesVerifier.java b/apix-verification/src/main/java/org/botstandards/apix/verification/O2OpenCorporatesVerifier.java new file mode 100644 index 0000000..2b4cfc6 --- /dev/null +++ b/apix-verification/src/main/java/org/botstandards/apix/verification/O2OpenCorporatesVerifier.java @@ -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 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 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()); + } + } +} diff --git a/apix-verification/src/main/java/org/botstandards/apix/verification/O3HygieneVerifier.java b/apix-verification/src/main/java/org/botstandards/apix/verification/O3HygieneVerifier.java new file mode 100644 index 0000000..759af6b --- /dev/null +++ b/apix-verification/src/main/java/org/botstandards/apix/verification/O3HygieneVerifier.java @@ -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 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 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 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 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()); + } + } +} diff --git a/apix-verification/src/main/java/org/botstandards/apix/verification/RotationChallengeVerifier.java b/apix-verification/src/main/java/org/botstandards/apix/verification/RotationChallengeVerifier.java new file mode 100644 index 0000000..ad8013c --- /dev/null +++ b/apix-verification/src/main/java/org/botstandards/apix/verification/RotationChallengeVerifier.java @@ -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 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 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); + } + } +} diff --git a/apix-verification/src/main/java/org/botstandards/apix/verification/VerificationConfig.java b/apix-verification/src/main/java/org/botstandards/apix/verification/VerificationConfig.java new file mode 100644 index 0000000..4d46422 --- /dev/null +++ b/apix-verification/src/main/java/org/botstandards/apix/verification/VerificationConfig.java @@ -0,0 +1,10 @@ +package org.botstandards.apix.verification; + +public record VerificationConfig( + String dohUrl, + String gleifApiUrl, + String openCorporatesApiKey, + String openCorporatesApiUrl, + String securityTxtUrlTemplate, + long httpTimeoutMs +) {} diff --git a/apix-verification/src/main/java/org/botstandards/apix/verification/VerificationPipeline.java b/apix-verification/src/main/java/org/botstandards/apix/verification/VerificationPipeline.java new file mode 100644 index 0000000..3e9b8b7 --- /dev/null +++ b/apix-verification/src/main/java/org/botstandards/apix/verification/VerificationPipeline.java @@ -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); + } +} diff --git a/infra/Caddyfile b/infra/Caddyfile new file mode 100644 index 0000000..3c2fef8 --- /dev/null +++ b/infra/Caddyfile @@ -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 +# 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" +# } diff --git a/infra/Dockerfile.demo b/infra/Dockerfile.demo new file mode 100644 index 0000000..c46f12b --- /dev/null +++ b/infra/Dockerfile.demo @@ -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"] diff --git a/infra/Dockerfile.portal b/infra/Dockerfile.portal new file mode 100644 index 0000000..3641a8c --- /dev/null +++ b/infra/Dockerfile.portal @@ -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"] diff --git a/infra/Dockerfile.registry b/infra/Dockerfile.registry new file mode 100644 index 0000000..a1ba397 --- /dev/null +++ b/infra/Dockerfile.registry @@ -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"] diff --git a/infra/Dockerfile.spider b/infra/Dockerfile.spider new file mode 100644 index 0000000..b257758 --- /dev/null +++ b/infra/Dockerfile.spider @@ -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"] diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index e1d2e7a..eac04ad 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -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: diff --git a/infra/grafana/provisioning/dashboards/apix-registry.json b/infra/grafana/provisioning/dashboards/apix-registry.json new file mode 100644 index 0000000..1890ef9 --- /dev/null +++ b/infra/grafana/provisioning/dashboards/apix-registry.json @@ -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" } + } + } + ] +} diff --git a/infra/grafana/provisioning/dashboards/provider.yml b/infra/grafana/provisioning/dashboards/provider.yml new file mode 100644 index 0000000..878cca7 --- /dev/null +++ b/infra/grafana/provisioning/dashboards/provider.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +providers: + - name: APIX + type: file + disableDeletion: true + updateIntervalSeconds: 30 + options: + path: /etc/grafana/provisioning/dashboards diff --git a/infra/grafana/provisioning/datasources/prometheus.yml b/infra/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..bb009bb --- /dev/null +++ b/infra/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false diff --git a/infra/prometheus.yml b/infra/prometheus.yml new file mode 100644 index 0000000..29254e7 --- /dev/null +++ b/infra/prometheus.yml @@ -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 diff --git a/scripts/deploy-all.ps1 b/scripts/deploy-all.ps1 new file mode 100644 index 0000000..a224ad2 --- /dev/null +++ b/scripts/deploy-all.ps1 @@ -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)" diff --git a/scripts/deploy-bluegreen.sh b/scripts/deploy-bluegreen.sh new file mode 100644 index 0000000..87248c3 --- /dev/null +++ b/scripts/deploy-bluegreen.sh @@ -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."