feat: refactor SPI/bootstrap to generic configurable providers
- Introduce a universal ConfigurableProvider/ProviderConfig abstraction for ServiceLoader-based components and align PKI bootstrapping utilities with it. - Document deterministic provider selection, property-based configuration conventions, and security requirements (never log config values), including package-level documentation for spi, spi.store and spi.bootstrap. fix: harden audit runtime, fix gzip scanning, add bounds and docs - Fix FileAuditSink concatenated gzip scan by shielding underlying stream - Use JUnit @TempDir for filesystem-backed tests - Bound InMemoryAuditSink with deterministic ring buffer - Add ServiceLoader smoke test and expand DefaultAuditService coverage - Improve JavaDoc and logging across audit implementation feat: add deterministic tests for PkiBootstrap with real SPI providers - add JUnit 5 test suite for PkiBootstrap - cover SPI selection for filesystem PkiStore and audit sinks - use @TempDir for filesystem-backed providers - register test ServiceLoader providers under src/test/resources - ensure deterministic bootstrap behavior via system properties Signed-off-by: Leo Galambos <lg@hq.egothor.org>
This commit is contained in:
@@ -6,26 +6,27 @@
|
|||||||
<attribute name="gradle_used_by_scope" value="main,test"/>
|
<attribute name="gradle_used_by_scope" value="main,test"/>
|
||||||
</attributes>
|
</attributes>
|
||||||
</classpathentry>
|
</classpathentry>
|
||||||
<classpathentry kind="src" output="pki/bin/main" path="src/main/resources"/>
|
<classpathentry kind="src" output="bin/main" path="src/main/resources">
|
||||||
|
<attributes>
|
||||||
|
<attribute name="gradle_scope" value="main"/>
|
||||||
|
<attribute name="gradle_used_by_scope" value="main,test"/>
|
||||||
|
</attributes>
|
||||||
|
</classpathentry>
|
||||||
<classpathentry kind="src" output="bin/test" path="src/test/java">
|
<classpathentry kind="src" output="bin/test" path="src/test/java">
|
||||||
<attributes>
|
<attributes>
|
||||||
<attribute name="test" value="true"/>
|
|
||||||
<attribute name="gradle_scope" value="test"/>
|
<attribute name="gradle_scope" value="test"/>
|
||||||
<attribute name="gradle_used_by_scope" value="test"/>
|
<attribute name="gradle_used_by_scope" value="test"/>
|
||||||
|
<attribute name="test" value="true"/>
|
||||||
</attributes>
|
</attributes>
|
||||||
</classpathentry>
|
</classpathentry>
|
||||||
<classpathentry kind="src" output="bin/test" path="src/test/resources">
|
<classpathentry kind="src" output="bin/test" path="src/test/resources">
|
||||||
<attributes>
|
<attributes>
|
||||||
<attribute name="test" value="true"/>
|
|
||||||
<attribute name="gradle_scope" value="test"/>
|
<attribute name="gradle_scope" value="test"/>
|
||||||
<attribute name="gradle_used_by_scope" value="test"/>
|
<attribute name="gradle_used_by_scope" value="test"/>
|
||||||
|
<attribute name="test" value="true"/>
|
||||||
</attributes>
|
</attributes>
|
||||||
</classpathentry>
|
</classpathentry>
|
||||||
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/">
|
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
|
||||||
<attributes>
|
|
||||||
<attribute name="module" value="true"/>
|
|
||||||
</attributes>
|
|
||||||
</classpathentry>
|
|
||||||
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
|
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
|
||||||
<classpathentry kind="output" path="bin/default"/>
|
<classpathentry kind="output" path="bin/default"/>
|
||||||
</classpath>
|
</classpath>
|
||||||
|
|||||||
@@ -35,40 +35,80 @@
|
|||||||
package zeroecho.pki.api.audit;
|
package zeroecho.pki.api.audit;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import zeroecho.pki.api.FormatId;
|
import zeroecho.pki.api.FormatId;
|
||||||
import zeroecho.pki.api.PkiId;
|
import zeroecho.pki.api.PkiId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auditable event emitted by the PKI core.
|
* Auditable event emitted by the PKI core and governance layer.
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* Audit events may represent high-level PKI operations (issuance, revocation,
|
* An {@code AuditEvent} is an immutable, structured record describing a
|
||||||
* publication, backup) and attribute access governance outcomes.
|
* security-relevant action or outcome. Typical event categories include
|
||||||
* Implementations must ensure no secrets appear in {@code details}.
|
* high-level PKI operations (issuance, revocation, publication, backup) and
|
||||||
|
* attribute-level governance decisions (read/export attempts and outcomes).
|
||||||
* </p>
|
* </p>
|
||||||
*
|
*
|
||||||
* @param time event time (server time)
|
* <h2>Security properties</h2>
|
||||||
* @param category non-empty category (e.g., "ISSUANCE", "REVOCATION",
|
* <ul>
|
||||||
* "ATTRIBUTE_ACCESS")
|
* <li><strong>No secrets:</strong> {@link #details()} MUST NOT contain secrets
|
||||||
* @param action non-empty action string (e.g., "ISSUE_END_ENTITY", "REVOKE",
|
* (keys, seeds, shared secrets, plaintext, private material, or other sensitive
|
||||||
* "READ")
|
* cryptographic/internal state). It is intended for non-sensitive metadata only
|
||||||
* @param principal actor responsible for the event
|
* (e.g., decision, policy id, reason codes, counters).</li>
|
||||||
* @param purpose purpose of the operation/access
|
* <li><strong>Minimality:</strong> callers should prefer coarse,
|
||||||
* @param objectId optional subject object id (credential id, request id, etc.)
|
* non-identifying fields and avoid excessive detail. If an identifier is
|
||||||
* @param formatId optional format id related to the object
|
* needed, prefer {@link #objectId()} and a stable, non-secret reference.</li>
|
||||||
* @param details additional non-sensitive key/value details
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Determinism and ordering</h2>
|
||||||
|
* <p>
|
||||||
|
* The static comparator returned by {@link #auditOrder()} defines a
|
||||||
|
* deterministic order for presentation and query results. It does not imply
|
||||||
|
* causality; it is purely a stable sort key.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <h2>Validation</h2>
|
||||||
|
* <p>
|
||||||
|
* This record enforces basic invariants: time is non-null, {@code category} and
|
||||||
|
* {@code action} are non-blank, {@code principal} and {@code purpose} are
|
||||||
|
* non-null, and optional containers/maps are non-null.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param time event time (server time), never {@code null}
|
||||||
|
* @param category non-blank category (e.g., {@code "ISSUANCE"},
|
||||||
|
* {@code "REVOCATION"}, {@code "ATTRIBUTE_ACCESS"})
|
||||||
|
* @param action non-blank action string (e.g., {@code "ISSUE_END_ENTITY"},
|
||||||
|
* {@code "REVOKE"}, {@code "READ"})
|
||||||
|
* @param principal actor responsible for the event, never {@code null}
|
||||||
|
* @param purpose declared purpose of the operation/access, never {@code null}
|
||||||
|
* @param objectId optional subject object id (credential id, request id,
|
||||||
|
* attribute id, etc.), never {@code null}
|
||||||
|
* @param formatId optional format id related to the object (e.g., encoding),
|
||||||
|
* never {@code null}
|
||||||
|
* @param details additional non-sensitive key/value details, never
|
||||||
|
* {@code null}
|
||||||
*/
|
*/
|
||||||
public record AuditEvent(Instant time, String category, String action, Principal principal, Purpose purpose,
|
public record AuditEvent(Instant time, String category, String action, Principal principal, Purpose purpose,
|
||||||
Optional<PkiId> objectId, Optional<FormatId> formatId, Map<String, String> details) {
|
Optional<PkiId> objectId, Optional<FormatId> formatId, Map<String, String> details) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an audit event.
|
* Creates an audit event and validates record invariants.
|
||||||
*
|
*
|
||||||
* @throws IllegalArgumentException if inputs are invalid or optional
|
* <p>
|
||||||
* containers/maps are null
|
* Note that this constructor does not (and cannot) automatically prove the
|
||||||
|
* absence of secrets in {@link #details()}. The responsibility to redact and
|
||||||
|
* constrain details is on the event producer.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException if {@code time} is {@code null},
|
||||||
|
* {@code category/action} are blank,
|
||||||
|
* {@code principal/purpose} are {@code null},
|
||||||
|
* or any of {@code objectId/formatId/details}
|
||||||
|
* are {@code null}
|
||||||
*/
|
*/
|
||||||
public AuditEvent {
|
public AuditEvent {
|
||||||
if (time == null) {
|
if (time == null) {
|
||||||
@@ -96,4 +136,78 @@ public record AuditEvent(Instant time, String category, String action, Principal
|
|||||||
throw new IllegalArgumentException("details must not be null");
|
throw new IllegalArgumentException("details must not be null");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a deterministic comparator for audit events.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The comparator orders events by: {@code time}, {@code category},
|
||||||
|
* {@code action}, {@code principal.type}, {@code principal.name}.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This ordering is intended for consistent presentation and stable test
|
||||||
|
* assertions. It is not a security primitive.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @return deterministic comparator, never {@code null}
|
||||||
|
*/
|
||||||
|
public static Comparator<AuditEvent> auditOrder() {
|
||||||
|
return Comparator.comparing(AuditEvent::time).thenComparing(AuditEvent::category)
|
||||||
|
.thenComparing(AuditEvent::action).thenComparing(e -> e.principal().type())
|
||||||
|
.thenComparing(e -> e.principal().name());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates whether this event matches a query constraint set.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Matching semantics:
|
||||||
|
* </p>
|
||||||
|
* <ul>
|
||||||
|
* <li>If {@code query.category} is present, it must equal
|
||||||
|
* {@link #category()}.</li>
|
||||||
|
* <li>If {@code query.action} is present, it must equal {@link #action()}.</li>
|
||||||
|
* <li>If {@code query.after} is present, event time must be {@code >= after}
|
||||||
|
* (implemented as {@code !time.isBefore(after)}).</li>
|
||||||
|
* <li>If {@code query.before} is present, event time must be {@code <= before}
|
||||||
|
* (implemented as {@code !time.isAfter(before)}).</li>
|
||||||
|
* <li>If {@code query.objectId} is present, it must equal {@link #objectId()}
|
||||||
|
* (empty optional does not match).</li>
|
||||||
|
* <li>If {@code query.principalName} is present, it must equal
|
||||||
|
* {@link #principal()}.{@code name()}.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This method is side-effect free and deterministic.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param query query constraints, must not be {@code null}
|
||||||
|
* @return {@code true} if this event matches all present constraints;
|
||||||
|
* {@code false} otherwise
|
||||||
|
* @throws NullPointerException if {@code query} is {@code null}
|
||||||
|
*/
|
||||||
|
public boolean matches(AuditQuery query) {
|
||||||
|
Objects.requireNonNull(query, "query");
|
||||||
|
|
||||||
|
if (query.category().isPresent() && !query.category().get().equals(category())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (query.action().isPresent() && !query.action().get().equals(action())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (query.after().isPresent() && time().isBefore(query.after().get())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (query.before().isPresent() && time().isAfter(query.before().get())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (query.objectId().isPresent() && !query.objectId().get().equals(objectId().orElse(null))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (query.principalName().isPresent() && !query.principalName().get().equals(principal().name())) { // NOPMD
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
285
pki/src/main/java/zeroecho/pki/impl/audit/AuditEventCodec.java
Normal file
285
pki/src/main/java/zeroecho/pki/impl/audit/AuditEventCodec.java
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.impl.audit;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.FormatId;
|
||||||
|
import zeroecho.pki.api.PkiId;
|
||||||
|
import zeroecho.pki.api.audit.AuditEvent;
|
||||||
|
import zeroecho.pki.api.audit.Principal;
|
||||||
|
import zeroecho.pki.api.audit.Purpose;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal codec for deterministic audit event identification and line-based
|
||||||
|
* persistence encoding.
|
||||||
|
*
|
||||||
|
* <h2>Stable id</h2>
|
||||||
|
* <p>
|
||||||
|
* {@link #eventId(AuditEvent)} computes SHA-256 over canonical bytes from
|
||||||
|
* {@link #canonicalBytes(AuditEvent)}. Canonicalization sorts
|
||||||
|
* {@link AuditEvent#details()} keys to ensure deterministic output.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <h2>Security</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>This codec does not log.</li>
|
||||||
|
* <li>It assumes audit details have already been redacted by the caller (no
|
||||||
|
* secrets).</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
final class AuditEventCodec {
|
||||||
|
|
||||||
|
private AuditEventCodec() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes a deterministic stable identifier for an audit event.
|
||||||
|
*
|
||||||
|
* @param event audit event (must not be null)
|
||||||
|
* @return stable id
|
||||||
|
* @throws IllegalArgumentException if {@code event} is null
|
||||||
|
*/
|
||||||
|
/* default */ static PkiId eventId(AuditEvent event) {
|
||||||
|
if (event == null) {
|
||||||
|
throw new IllegalArgumentException("event must not be null");
|
||||||
|
}
|
||||||
|
byte[] bytes = canonicalBytes(event);
|
||||||
|
byte[] digest = sha256(bytes);
|
||||||
|
return new PkiId(toHex(digest));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces canonical UTF-8 bytes used as input to stable hashing.
|
||||||
|
*
|
||||||
|
* @param event audit event (must not be null)
|
||||||
|
* @return canonical bytes
|
||||||
|
* @throws IllegalArgumentException if {@code event} is null
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("PMD")
|
||||||
|
/* default */ static byte[] canonicalBytes(AuditEvent event) {
|
||||||
|
if (event == null) {
|
||||||
|
throw new IllegalArgumentException("event must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder(256);
|
||||||
|
sb.append("time=").append(event.time().toString()).append('\n');
|
||||||
|
sb.append("category=").append(event.category()).append('\n');
|
||||||
|
sb.append("action=").append(event.action()).append('\n');
|
||||||
|
sb.append("principal.type=").append(event.principal().type()).append('\n');
|
||||||
|
sb.append("principal.name=").append(event.principal().name()).append('\n');
|
||||||
|
sb.append("purpose=").append(event.purpose().value()).append('\n');
|
||||||
|
|
||||||
|
Optional<PkiId> objectId = event.objectId();
|
||||||
|
sb.append("objectId=").append(objectId.isPresent() ? objectId.get().value() : "").append('\n');
|
||||||
|
|
||||||
|
Optional<FormatId> formatId = event.formatId();
|
||||||
|
sb.append("formatId=").append(formatId.isPresent() ? formatId.get().value() : "").append('\n');
|
||||||
|
|
||||||
|
TreeMap<String, String> sorted = new TreeMap<String, String>();
|
||||||
|
sorted.putAll(event.details());
|
||||||
|
for (Map.Entry<String, String> e : sorted.entrySet()) {
|
||||||
|
sb.append("d.").append(e.getKey()).append('=').append(e.getValue()).append('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString().getBytes(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes an event into a deterministic single-line representation
|
||||||
|
* (newline-terminated).
|
||||||
|
*
|
||||||
|
* @param event audit event (must not be null)
|
||||||
|
* @return encoded line
|
||||||
|
* @throws IllegalArgumentException if {@code event} is null
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("PMD")
|
||||||
|
/* default */ static String encodeLine(AuditEvent event) {
|
||||||
|
if (event == null) {
|
||||||
|
throw new IllegalArgumentException("event must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder(256);
|
||||||
|
sb.append("t=").append(event.time().toString()).append('|');
|
||||||
|
sb.append("c=").append(escape(event.category())).append('|');
|
||||||
|
sb.append("a=").append(escape(event.action())).append('|');
|
||||||
|
sb.append("pt=").append(escape(event.principal().type())).append('|');
|
||||||
|
sb.append("pn=").append(escape(event.principal().name())).append('|');
|
||||||
|
sb.append("pu=").append(escape(event.purpose().value())).append('|');
|
||||||
|
sb.append("oid=").append(escape(event.objectId().isPresent() ? event.objectId().get().value() : ""))
|
||||||
|
.append('|');
|
||||||
|
sb.append("fid=").append(escape(event.formatId().isPresent() ? event.formatId().get().value() : ""))
|
||||||
|
.append('|');
|
||||||
|
|
||||||
|
TreeMap<String, String> sorted = new TreeMap<String, String>();
|
||||||
|
sorted.putAll(event.details());
|
||||||
|
|
||||||
|
boolean first = true;
|
||||||
|
for (Map.Entry<String, String> e : sorted.entrySet()) {
|
||||||
|
if (!first) {
|
||||||
|
sb.append(',');
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
sb.append(escape(e.getKey())).append('=').append(escape(e.getValue()));
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append('\n');
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a single encoded line created by {@link #encodeLine(AuditEvent)}.
|
||||||
|
*
|
||||||
|
* @param line encoded line (must not be null or blank)
|
||||||
|
* @return decoded event
|
||||||
|
* @throws IllegalArgumentException if the input is malformed or missing
|
||||||
|
* required fields
|
||||||
|
*/
|
||||||
|
/* default */ static AuditEvent decodeLine(String line) {
|
||||||
|
if (line == null || line.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("line must not be null/blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
String trimmed = line.strip();
|
||||||
|
String[] parts = trimmed.split("\\|", -1);
|
||||||
|
|
||||||
|
Instant time = Instant.parse(value(parts, "t"));
|
||||||
|
String category = unescape(value(parts, "c"));
|
||||||
|
String action = unescape(value(parts, "a"));
|
||||||
|
|
||||||
|
Principal principal = new Principal(unescape(value(parts, "pt")), unescape(value(parts, "pn")));
|
||||||
|
Purpose purpose = new Purpose(unescape(value(parts, "pu")));
|
||||||
|
|
||||||
|
String oid = unescape(value(parts, "oid"));
|
||||||
|
Optional<PkiId> objectId = oid.isBlank() ? Optional.empty() : Optional.of(new PkiId(oid));
|
||||||
|
|
||||||
|
String fid = unescape(value(parts, "fid"));
|
||||||
|
Optional<FormatId> formatId = fid.isBlank() ? Optional.empty() : Optional.of(new FormatId(fid));
|
||||||
|
|
||||||
|
Map<String, String> details = decodeDetails(parts.length > 8 ? parts[8] : "");
|
||||||
|
return new AuditEvent(time, category, action, principal, purpose, objectId, formatId, Map.copyOf(details));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> decodeDetails(String detailsPart) {
|
||||||
|
Map<String, String> details = new TreeMap<>();
|
||||||
|
if (detailsPart == null || detailsPart.isBlank()) {
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] kvs = detailsPart.split(",", -1);
|
||||||
|
for (String kv : kvs) {
|
||||||
|
int idx = kv.indexOf('=');
|
||||||
|
if (idx <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String k = unescape(kv.substring(0, idx));
|
||||||
|
String v = unescape(kv.substring(idx + 1));
|
||||||
|
if (!k.isBlank()) {
|
||||||
|
details.put(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String value(String[] parts, String key) {
|
||||||
|
String prefix = key + "=";
|
||||||
|
for (String p : parts) {
|
||||||
|
if (p.startsWith(prefix)) {
|
||||||
|
return p.substring(prefix.length());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Missing required field: " + key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String escape(String s) {
|
||||||
|
String x = s.replace("\\", "\\\\");
|
||||||
|
x = x.replace("|", "\\|");
|
||||||
|
x = x.replace("\n", "\\n");
|
||||||
|
x = x.replace("\r", "\\r");
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("PMD")
|
||||||
|
private static String unescape(String s) {
|
||||||
|
StringBuilder out = new StringBuilder(s.length());
|
||||||
|
boolean esc = false;
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char ch = s.charAt(i);
|
||||||
|
if (!esc) {
|
||||||
|
if (ch == '\\') {
|
||||||
|
esc = true;
|
||||||
|
} else {
|
||||||
|
out.append(ch);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
esc = false;
|
||||||
|
if (ch == 'n') {
|
||||||
|
out.append('\n');
|
||||||
|
} else if (ch == 'r') {
|
||||||
|
out.append('\r');
|
||||||
|
} else {
|
||||||
|
out.append(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] sha256(byte[] input) {
|
||||||
|
try {
|
||||||
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
|
return md.digest(input);
|
||||||
|
} catch (NoSuchAlgorithmException ex) {
|
||||||
|
throw new IllegalStateException("SHA-256 not available", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String toHex(byte[] bytes) {
|
||||||
|
char[] hex = new char[bytes.length * 2];
|
||||||
|
final char[] alphabet = "0123456789abcdef".toCharArray();
|
||||||
|
for (int i = 0; i < bytes.length; i++) {
|
||||||
|
int v = bytes[i] & 0xFF;
|
||||||
|
hex[i * 2] = alphabet[v >>> 4];
|
||||||
|
hex[i * 2 + 1] = alphabet[v & 0x0F];
|
||||||
|
}
|
||||||
|
return new String(hex);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.impl.audit;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.attr.AttributeAccessPolicy;
|
||||||
|
import zeroecho.pki.api.attr.AttributeCatalogue;
|
||||||
|
import zeroecho.pki.api.attr.AttributeDefinition;
|
||||||
|
import zeroecho.pki.api.attr.AttributeExportTarget;
|
||||||
|
import zeroecho.pki.api.attr.AttributeId;
|
||||||
|
import zeroecho.pki.api.attr.AttributeSensitivity;
|
||||||
|
import zeroecho.pki.api.attr.AttributeSet;
|
||||||
|
import zeroecho.pki.api.attr.AttributeValue;
|
||||||
|
import zeroecho.pki.api.attr.AttributeValue.BytesValue;
|
||||||
|
import zeroecho.pki.api.audit.AccessAction;
|
||||||
|
import zeroecho.pki.api.audit.AccessContext;
|
||||||
|
import zeroecho.pki.api.audit.AccessDecision;
|
||||||
|
import zeroecho.pki.api.audit.AttributeAccessController;
|
||||||
|
import zeroecho.pki.api.audit.AttributeGovernanceService;
|
||||||
|
import zeroecho.pki.api.audit.AuditEvent;
|
||||||
|
import zeroecho.pki.api.audit.AuditService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default enforcement implementation of {@link AttributeGovernanceService}.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This component acts as a Policy Enforcement Point (PEP). For attribute
|
||||||
|
* operations it:
|
||||||
|
* </p>
|
||||||
|
* <ol>
|
||||||
|
* <li>resolves attribute metadata from {@link AttributeCatalogue},</li>
|
||||||
|
* <li>obtains a decision from {@link AttributeAccessController} (PDP),</li>
|
||||||
|
* <li>enforces allow/deny deterministically,</li>
|
||||||
|
* <li>emits audit events via {@link AuditService} (if configured by
|
||||||
|
* policy).</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <h2>Security properties</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li><strong>No secrets in audit:</strong> audit {@link AuditEvent#details()}
|
||||||
|
* contains only metadata. Attribute values are never included.</li>
|
||||||
|
* <li><strong>Deterministic time:</strong> timestamps are sourced from an
|
||||||
|
* injected {@link Clock}.</li>
|
||||||
|
* <li><strong>Export redaction:</strong> non-public attributes are
|
||||||
|
* deterministically redacted during export.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Logging</h2>
|
||||||
|
* <p>
|
||||||
|
* This class logs operational failures (e.g., PDP throws) and unexpected
|
||||||
|
* states, but never logs attribute values.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public final class DefaultAttributeGovernanceService implements AttributeGovernanceService {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(DefaultAttributeGovernanceService.class.getName());
|
||||||
|
private static final String CATEGORY = "ATTRIBUTE_GOVERNANCE";
|
||||||
|
|
||||||
|
private final AttributeAccessController accessController;
|
||||||
|
private final AuditService auditService;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a governance service.
|
||||||
|
*
|
||||||
|
* @param accessController decision point (PDP)
|
||||||
|
* @param auditService audit service for recording decisions
|
||||||
|
* @param clock time source
|
||||||
|
* @throws IllegalArgumentException if any argument is null
|
||||||
|
*/
|
||||||
|
public DefaultAttributeGovernanceService(AttributeAccessController accessController, AuditService auditService,
|
||||||
|
Clock clock) {
|
||||||
|
if (accessController == null) {
|
||||||
|
throw new IllegalArgumentException("accessController must not be null");
|
||||||
|
}
|
||||||
|
if (auditService == null) {
|
||||||
|
throw new IllegalArgumentException("auditService must not be null");
|
||||||
|
}
|
||||||
|
if (clock == null) {
|
||||||
|
throw new IllegalArgumentException("clock must not be null");
|
||||||
|
}
|
||||||
|
this.accessController = accessController;
|
||||||
|
this.auditService = auditService;
|
||||||
|
this.clock = clock;
|
||||||
|
|
||||||
|
LOGGER.log(Level.INFO, "DefaultAttributeGovernanceService initialized.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<AttributeValue> read(AttributeCatalogue catalogue, AttributeSet set, AttributeId id,
|
||||||
|
AccessContext context) {
|
||||||
|
Objects.requireNonNull(catalogue, "catalogue");
|
||||||
|
Objects.requireNonNull(set, "set");
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
Objects.requireNonNull(context, "context");
|
||||||
|
|
||||||
|
Optional<AttributeDefinition> definition = catalogue.find(id);
|
||||||
|
if (definition.isEmpty()) {
|
||||||
|
LOGGER.log(Level.FINE, "Attribute not found in catalogue; denying READ.");
|
||||||
|
auditIfConfigured(null, id, AccessAction.READ, context, AccessDecision.DENY);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
AccessDecision decision = decideSafely(definition.get(), AccessAction.READ, context);
|
||||||
|
auditIfConfigured(definition.get(), id, AccessAction.READ, context, decision);
|
||||||
|
|
||||||
|
if (decision != AccessDecision.ALLOW) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return set.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AttributeSet write(AttributeCatalogue catalogue, AttributeSet set, AttributeId id, AttributeValue value,
|
||||||
|
AccessContext context) {
|
||||||
|
Objects.requireNonNull(catalogue, "catalogue");
|
||||||
|
Objects.requireNonNull(set, "set");
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
Objects.requireNonNull(value, "value");
|
||||||
|
Objects.requireNonNull(context, "context");
|
||||||
|
|
||||||
|
Optional<AttributeDefinition> definition = catalogue.find(id);
|
||||||
|
if (definition.isEmpty()) {
|
||||||
|
LOGGER.log(Level.FINE, "Attribute not found in catalogue; denying WRITE.");
|
||||||
|
auditIfConfigured(null, id, AccessAction.WRITE, context, AccessDecision.DENY);
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
AccessDecision decision = decideSafely(definition.get(), AccessAction.WRITE, context);
|
||||||
|
auditIfConfigured(definition.get(), id, AccessAction.WRITE, context, decision);
|
||||||
|
|
||||||
|
if (decision != AccessDecision.ALLOW) {
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AttributeSetCopies.withSingleValue(set, id, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<AttributeValue> export(AttributeCatalogue catalogue, AttributeSet set, AttributeId id,
|
||||||
|
AccessContext context) {
|
||||||
|
Objects.requireNonNull(catalogue, "catalogue");
|
||||||
|
Objects.requireNonNull(set, "set");
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
Objects.requireNonNull(context, "context");
|
||||||
|
|
||||||
|
Optional<AttributeDefinition> definition = catalogue.find(id);
|
||||||
|
if (definition.isEmpty()) {
|
||||||
|
LOGGER.log(Level.FINE, "Attribute not found in catalogue; denying EXPORT.");
|
||||||
|
auditIfConfigured(null, id, AccessAction.EXPORT, context, AccessDecision.DENY);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
AccessDecision decision = decideSafely(definition.get(), AccessAction.EXPORT, context);
|
||||||
|
if (decision == AccessDecision.ALLOW) {
|
||||||
|
AttributeAccessPolicy policy = definition.get().accessPolicy();
|
||||||
|
if (!isExportTargetAllowed(policy, context)) {
|
||||||
|
decision = AccessDecision.DENY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auditIfConfigured(definition.get(), id, AccessAction.EXPORT, context, decision);
|
||||||
|
|
||||||
|
if (decision != AccessDecision.ALLOW) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<AttributeValue> value = set.get(id);
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Optional.of(redactForExport(definition.get().sensitivity(), value.get()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AttributeSet derive(AttributeCatalogue catalogue, AttributeSet set, AttributeId id, AccessContext context) {
|
||||||
|
Objects.requireNonNull(catalogue, "catalogue");
|
||||||
|
Objects.requireNonNull(set, "set");
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
Objects.requireNonNull(context, "context");
|
||||||
|
|
||||||
|
Optional<AttributeDefinition> definition = catalogue.find(id);
|
||||||
|
if (definition.isEmpty()) {
|
||||||
|
LOGGER.log(Level.FINE, "Attribute not found in catalogue; denying DERIVE.");
|
||||||
|
auditIfConfigured(null, id, AccessAction.DERIVE, context, AccessDecision.DENY);
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
AccessDecision decision = decideSafely(definition.get(), AccessAction.DERIVE, context);
|
||||||
|
auditIfConfigured(definition.get(), id, AccessAction.DERIVE, context, decision);
|
||||||
|
|
||||||
|
if (decision != AccessDecision.ALLOW) {
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<AttributeValue> existing = set.get(id);
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baseline deterministic derivation (no domain logic; does not use secrets).
|
||||||
|
AttributeValue derived = new AttributeValue.StringValue("derived:" + id.value());
|
||||||
|
return AttributeSetCopies.withSingleValue(set, id, derived);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps PDP decision with defensive error handling.
|
||||||
|
*
|
||||||
|
* @param definition attribute definition
|
||||||
|
* @param action access action
|
||||||
|
* @param context access context
|
||||||
|
* @return PDP decision or DENY on failure
|
||||||
|
*/
|
||||||
|
private AccessDecision decideSafely(AttributeDefinition definition, AccessAction action, AccessContext context) {
|
||||||
|
try {
|
||||||
|
return accessController.decide(definition, action, context);
|
||||||
|
} catch (RuntimeException ex) { // NOPMD
|
||||||
|
// Never log attribute value; only operational message.
|
||||||
|
LOGGER.log(Level.SEVERE, "Access controller failed; defaulting to DENY.", ex);
|
||||||
|
return AccessDecision.DENY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits an audit record if configured by policy.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Only metadata is recorded. No attribute values are included.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param definitionOrNull attribute definition or null if unknown attribute
|
||||||
|
* @param id attribute id
|
||||||
|
* @param action access action
|
||||||
|
* @param context access context
|
||||||
|
* @param decision access decision
|
||||||
|
*/
|
||||||
|
private void auditIfConfigured(AttributeDefinition definitionOrNull, AttributeId id, AccessAction action,
|
||||||
|
AccessContext context, AccessDecision decision) {
|
||||||
|
|
||||||
|
boolean audit = shouldAudit(definitionOrNull, decision);
|
||||||
|
if (!audit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Instant now = clock.instant();
|
||||||
|
|
||||||
|
Map<String, String> details = new HashMap<>();
|
||||||
|
details.put("attributeId", id.value());
|
||||||
|
details.put("decision", decision.name());
|
||||||
|
details.put("principalType", context.principal().type());
|
||||||
|
details.put("principalName", context.principal().name());
|
||||||
|
details.put("purpose", context.purpose().value());
|
||||||
|
details.put("sensitivity", definitionOrNull != null ? definitionOrNull.sensitivity().name() : "UNKNOWN");
|
||||||
|
|
||||||
|
AuditEvent event = new AuditEvent(now, CATEGORY, action.name(), context.principal(), context.purpose(),
|
||||||
|
context.objectId(), context.formatId(), Map.copyOf(details));
|
||||||
|
|
||||||
|
auditService.record(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean shouldAudit(AttributeDefinition definitionOrNull, AccessDecision decision) {
|
||||||
|
if (definitionOrNull == null) {
|
||||||
|
// Unknown attribute accesses are always audited.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
AttributeAccessPolicy policy = definitionOrNull.accessPolicy();
|
||||||
|
if (decision == AccessDecision.ALLOW) {
|
||||||
|
return policy.auditOnAllow();
|
||||||
|
}
|
||||||
|
return policy.auditOnDeny();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isExportTargetAllowed(AttributeAccessPolicy policy, AccessContext context) {
|
||||||
|
if (policy.exportTargets().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baseline mapping: Purpose.value() -> AttributeExportTarget.name()
|
||||||
|
// (case-insensitive).
|
||||||
|
String purpose = context.purpose().value().trim().toUpperCase(Locale.ROOT);
|
||||||
|
for (AttributeExportTarget allowed : policy.exportTargets()) {
|
||||||
|
if (allowed.name().equalsIgnoreCase(purpose)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AttributeValue redactForExport(AttributeSensitivity sensitivity, AttributeValue value) {
|
||||||
|
if (sensitivity == AttributeSensitivity.PUBLIC) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof AttributeValue.StringValue) {
|
||||||
|
return new AttributeValue.StringValue("[REDACTED]");
|
||||||
|
}
|
||||||
|
if (value instanceof AttributeValue.BooleanValue) {
|
||||||
|
return new AttributeValue.BooleanValue(false);
|
||||||
|
}
|
||||||
|
if (value instanceof AttributeValue.IntegerValue) {
|
||||||
|
return new AttributeValue.IntegerValue(0L);
|
||||||
|
}
|
||||||
|
if (value instanceof AttributeValue.InstantValue) {
|
||||||
|
return new AttributeValue.InstantValue(Instant.EPOCH);
|
||||||
|
}
|
||||||
|
if (value instanceof BytesValue) {
|
||||||
|
return new BytesValue(new byte[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AttributeValue.StringValue("[REDACTED]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal helper for producing immutable {@link AttributeSet} copies with
|
||||||
|
* modifications.
|
||||||
|
*/
|
||||||
|
/* default */ static final class AttributeSetCopies {
|
||||||
|
|
||||||
|
private AttributeSetCopies() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a new set with exactly one value stored under the given id (replacing
|
||||||
|
* existing values).
|
||||||
|
*
|
||||||
|
* @param input original set
|
||||||
|
* @param id attribute id
|
||||||
|
* @param value attribute value
|
||||||
|
* @return new immutable attribute set
|
||||||
|
*/
|
||||||
|
/* default */ static AttributeSet withSingleValue(AttributeSet input, AttributeId id, AttributeValue value) {
|
||||||
|
Map<AttributeId, List<AttributeValue>> map = new HashMap<>();
|
||||||
|
for (AttributeId existingId : input.ids()) {
|
||||||
|
List<AttributeValue> values = input.getAll(existingId);
|
||||||
|
map.put(existingId, List.copyOf(values));
|
||||||
|
}
|
||||||
|
map.put(id, List.of(value));
|
||||||
|
return new MapBackedAttributeSet(Map.copyOf(map));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable, map-backed {@link AttributeSet} implementation used by this
|
||||||
|
* reference PEP.
|
||||||
|
*/
|
||||||
|
/* default */ static final class MapBackedAttributeSet implements AttributeSet {
|
||||||
|
|
||||||
|
private final Map<AttributeId, List<AttributeValue>> values;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new attribute set backed by an immutable map.
|
||||||
|
*
|
||||||
|
* @param values attribute storage map
|
||||||
|
*/
|
||||||
|
/* default */ MapBackedAttributeSet(Map<AttributeId, List<AttributeValue>> values) {
|
||||||
|
this.values = Objects.requireNonNull(values, "values");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public java.util.Set<AttributeId> ids() {
|
||||||
|
return values.keySet();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<AttributeValue> get(AttributeId id) {
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
List<AttributeValue> list = values.get(id);
|
||||||
|
if (list == null || list.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(list.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AttributeValue> getAll(AttributeId id) {
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
List<AttributeValue> list = values.get(id);
|
||||||
|
if (list == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return List.copyOf(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
365
pki/src/main/java/zeroecho/pki/impl/audit/FileAuditSink.java
Normal file
365
pki/src/main/java/zeroecho/pki/impl/audit/FileAuditSink.java
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.impl.audit;
|
||||||
|
|
||||||
|
import java.io.BufferedInputStream;
|
||||||
|
import java.io.BufferedOutputStream;
|
||||||
|
import java.io.FilterInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.io.PushbackInputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.FileAlreadyExistsException;
|
||||||
|
import java.nio.file.FileSystems;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.OpenOption;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.nio.file.attribute.PosixFilePermission;
|
||||||
|
import java.nio.file.attribute.PosixFilePermissions;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import java.util.zip.GZIPInputStream;
|
||||||
|
import java.util.zip.GZIPOutputStream;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.PkiId;
|
||||||
|
import zeroecho.pki.api.audit.AuditEvent;
|
||||||
|
import zeroecho.pki.api.audit.AuditQuery;
|
||||||
|
import zeroecho.pki.spi.audit.AuditSink;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent {@link AuditSink} provider storing audit events as daily gzip
|
||||||
|
* files.
|
||||||
|
*
|
||||||
|
* <h2>ServiceLoader compatibility</h2>
|
||||||
|
* <p>
|
||||||
|
* This sink supports {@link java.util.ServiceLoader} by providing a public
|
||||||
|
* no-arg constructor.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <h2>Storage layout</h2> <pre>
|
||||||
|
* baseDir/
|
||||||
|
* YYYY/
|
||||||
|
* MM/
|
||||||
|
* YYYY-MM-DD.audit.log.gz
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <h2>Encoding</h2>
|
||||||
|
* <p>
|
||||||
|
* Each event is encoded as a single UTF-8 line and appended as its own gzip
|
||||||
|
* member. Members are concatenated to enable append-only writes.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <h2>Security properties</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>Best-effort permissions: directories 0700, files 0600 on POSIX.</li>
|
||||||
|
* <li>No event payload is logged. Corrupted records are skipped without logging
|
||||||
|
* the record content.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class FileAuditSink implements AuditSink, SearchableAuditSink {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(FileAuditSink.class.getName());
|
||||||
|
|
||||||
|
private final Path baseDir;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the sink with an explicit base directory.
|
||||||
|
*
|
||||||
|
* @param baseDir base directory
|
||||||
|
*/
|
||||||
|
public FileAuditSink(Path baseDir) {
|
||||||
|
this.baseDir = baseDir;
|
||||||
|
|
||||||
|
LOG.log(Level.INFO, "FileAuditSink enabled; baseDir name={0}", baseDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void record(AuditEvent event) {
|
||||||
|
if (event == null) {
|
||||||
|
throw new IllegalArgumentException("event must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
Path file = dailyFile(baseDir, event.time());
|
||||||
|
ensureSecurePath(file.getParent(), file);
|
||||||
|
|
||||||
|
String line = AuditEventCodec.encodeLine(event);
|
||||||
|
byte[] bytes = line.getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
OpenOption[] opts = { StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND };
|
||||||
|
|
||||||
|
try (OutputStream os = new BufferedOutputStream(Files.newOutputStream(file, opts));
|
||||||
|
GZIPOutputStream gzip = new GZIPOutputStream(os, true)) {
|
||||||
|
gzip.write(bytes);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
if (LOG.isLoggable(Level.SEVERE)) {
|
||||||
|
LOG.log(Level.SEVERE, "Failed to write audit record to file: " + file.getFileName(), ex);
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Failed to write audit event.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AuditEvent> search(AuditQuery query) {
|
||||||
|
if (query == null) {
|
||||||
|
throw new IllegalArgumentException("query must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Path> files = FileAuditSinkFiles.listDailyFiles(baseDir);
|
||||||
|
if (files.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LOG.isLoggable(Level.FINE)) {
|
||||||
|
LOG.log(Level.FINE, "FileAuditSink search scanning {0} file(s).", files.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AuditEvent> out = new ArrayList<>();
|
||||||
|
for (Path f : files) {
|
||||||
|
scanFile(f, query, out, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort(AuditEvent.auditOrder());
|
||||||
|
return List.copyOf(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<AuditEvent> getById(PkiId eventId) {
|
||||||
|
if (eventId == null) {
|
||||||
|
throw new IllegalArgumentException("eventId must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Path> files = FileAuditSinkFiles.listDailyFiles(baseDir);
|
||||||
|
for (Path f : files) {
|
||||||
|
List<AuditEvent> tmp = new ArrayList<>(); // NOPMD
|
||||||
|
scanFile(f, null, tmp, eventId);
|
||||||
|
if (!tmp.isEmpty()) {
|
||||||
|
return Optional.of(tmp.get(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scanFile(Path file, AuditQuery queryOrNull, List<AuditEvent> out, PkiId idOrNull) {
|
||||||
|
if (!Files.isRegularFile(file)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try (InputStream fis = new BufferedInputStream(Files.newInputStream(file));
|
||||||
|
PushbackInputStream pb = new PushbackInputStream(fis, 1)) {
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
int peek = pb.read();
|
||||||
|
if (peek == -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pb.unread(peek);
|
||||||
|
|
||||||
|
try (GZIPInputStream gzip = new GZIPInputStream(new CloseShieldInputStream(pb))) { // NOPMD
|
||||||
|
readLinesFromMember(gzip, queryOrNull, out, idOrNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idOrNull != null && !out.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (IOException ex) {
|
||||||
|
if (LOG.isLoggable(Level.SEVERE)) {
|
||||||
|
LOG.log(Level.SEVERE, "Failed to read audit file: " + file.getFileName(), ex);
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Failed to read audit file: " + file, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void readLinesFromMember(InputStream gzip, AuditQuery queryOrNull, List<AuditEvent> out, PkiId idOrNull)
|
||||||
|
throws IOException {
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder(256);
|
||||||
|
int b;
|
||||||
|
while ((b = gzip.read()) != -1) {
|
||||||
|
if (b == '\n') { // NOPMD
|
||||||
|
processLine(sb.toString(), queryOrNull, out, idOrNull);
|
||||||
|
sb.setLength(0);
|
||||||
|
if (idOrNull != null && !out.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sb.append((char) b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sb.length() > 0) {
|
||||||
|
processLine(sb.toString(), queryOrNull, out, idOrNull);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void processLine(String line, AuditQuery queryOrNull, List<AuditEvent> out, PkiId idOrNull) {
|
||||||
|
if (line == null || line.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditEvent e;
|
||||||
|
try {
|
||||||
|
e = AuditEventCodec.decodeLine(line);
|
||||||
|
} catch (RuntimeException ex) { // NOPMD
|
||||||
|
LOG.log(Level.WARNING, "Corrupted audit record encountered; skipping.", ex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idOrNull != null) {
|
||||||
|
if (AuditEventCodec.eventId(e).equals(idOrNull)) {
|
||||||
|
out.add(e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryOrNull == null) {
|
||||||
|
out.add(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.matches(queryOrNull)) {
|
||||||
|
out.add(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path dailyFile(Path baseDir, Instant time) {
|
||||||
|
LocalDate date = LocalDate.ofInstant(time, ZoneOffset.UTC);
|
||||||
|
String yyyy = String.format("%04d", date.getYear());
|
||||||
|
String mm = String.format("%02d", date.getMonthValue());
|
||||||
|
String filename = String.format("%04d-%02d-%02d.audit.log.gz", date.getYear(), date.getMonthValue(),
|
||||||
|
date.getDayOfMonth());
|
||||||
|
return baseDir.resolve(yyyy).resolve(mm).resolve(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureSecurePath(Path dir, Path file) {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(dir);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
LOG.log(Level.SEVERE, "Failed to create audit directory.", ex);
|
||||||
|
throw new IllegalStateException("Failed to create audit directory: " + dir, ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDirectoryPermissions(dir);
|
||||||
|
|
||||||
|
if (!Files.exists(file)) {
|
||||||
|
try {
|
||||||
|
Files.createFile(file);
|
||||||
|
} catch (FileAlreadyExistsException ignored) {
|
||||||
|
// acceptable race
|
||||||
|
} catch (IOException ex) {
|
||||||
|
if (LOG.isLoggable(Level.SEVERE)) {
|
||||||
|
LOG.log(Level.SEVERE, "Failed to create audit file: " + file.getFileName(), ex);
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Failed to create audit file: " + file, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilePermissions(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyDirectoryPermissions(Path dir) {
|
||||||
|
if (supportsPosix()) {
|
||||||
|
try {
|
||||||
|
Set<PosixFilePermission> dirPerms = PosixFilePermissions.fromString("rwx------");
|
||||||
|
Files.setPosixFilePermissions(dir, dirPerms);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
LOG.log(Level.WARNING, "Failed to apply POSIX permissions to audit directory.", ex);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean r = dir.toFile().setReadable(true, true);
|
||||||
|
boolean w = dir.toFile().setWritable(true, true);
|
||||||
|
boolean x = dir.toFile().setExecutable(true, true);
|
||||||
|
if (!r || !w || !x) {
|
||||||
|
LOG.log(Level.WARNING, "Failed to apply basic permissions to audit directory (non-POSIX FS).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyFilePermissions(Path file) {
|
||||||
|
if (supportsPosix()) {
|
||||||
|
try {
|
||||||
|
Set<PosixFilePermission> filePerms = PosixFilePermissions.fromString("rw-------");
|
||||||
|
Files.setPosixFilePermissions(file, filePerms);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
LOG.log(Level.WARNING, "Failed to apply POSIX permissions to audit file.", ex);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean r = file.toFile().setReadable(true, true);
|
||||||
|
boolean w = file.toFile().setWritable(true, true);
|
||||||
|
boolean x = file.toFile().setExecutable(false, true);
|
||||||
|
if (!r || !w || !x) {
|
||||||
|
LOG.log(Level.WARNING, "Failed to apply basic permissions to audit file (non-POSIX FS).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean supportsPosix() {
|
||||||
|
return FileSystems.getDefault().supportedFileAttributeViews().contains("posix");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InputStream wrapper that prevents accidental closing of the underlying
|
||||||
|
* stream.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* {@link java.util.zip.GZIPInputStream#close()} closes the wrapped stream. When
|
||||||
|
* scanning concatenated gzip members, we must keep the outer stream open and
|
||||||
|
* only close the inflater state of each member.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
private static final class CloseShieldInputStream extends FilterInputStream {
|
||||||
|
|
||||||
|
private CloseShieldInputStream(InputStream in) {
|
||||||
|
super(in);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws IOException {
|
||||||
|
// Intentionally do nothing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.impl.audit;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal utilities for enumerating persisted audit files.
|
||||||
|
*/
|
||||||
|
final class FileAuditSinkFiles {
|
||||||
|
|
||||||
|
private FileAuditSinkFiles() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all {@code *.audit.log.gz} files under {@code baseDir}
|
||||||
|
* deterministically.
|
||||||
|
*
|
||||||
|
* @param baseDir base directory (must not be null)
|
||||||
|
* @return immutable, lexicographically sorted list of paths
|
||||||
|
* @throws IllegalArgumentException if {@code baseDir} is null
|
||||||
|
* @throws IllegalStateException if filesystem traversal fails
|
||||||
|
*/
|
||||||
|
/* default */ static List<Path> listDailyFiles(Path baseDir) {
|
||||||
|
if (baseDir == null) {
|
||||||
|
throw new IllegalArgumentException("baseDir must not be null");
|
||||||
|
}
|
||||||
|
if (!Files.isDirectory(baseDir)) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Path> out = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
Files.walk(baseDir).filter(Files::isRegularFile)
|
||||||
|
.filter(p -> p.getFileName().toString().endsWith(".audit.log.gz")).forEach(out::add);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw new IllegalStateException("Failed to list audit files.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort(Comparator.naturalOrder());
|
||||||
|
return List.copyOf(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.impl.audit;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import zeroecho.pki.spi.ProviderConfig;
|
||||||
|
import zeroecho.pki.spi.audit.AuditSink;
|
||||||
|
import zeroecho.pki.spi.audit.AuditSinkProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public class FileAuditSinkProvider implements AuditSinkProvider {
|
||||||
|
@Override
|
||||||
|
public String id() {
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<String> supportedKeys() {
|
||||||
|
return Set.of("root");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AuditSink allocate(ProviderConfig config) {
|
||||||
|
Objects.requireNonNull(config, "config");
|
||||||
|
|
||||||
|
String rootString = config.require("root");
|
||||||
|
Path root = Path.of(rootString);
|
||||||
|
|
||||||
|
return new FileAuditSink(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
190
pki/src/main/java/zeroecho/pki/impl/audit/InMemoryAuditSink.java
Normal file
190
pki/src/main/java/zeroecho/pki/impl/audit/InMemoryAuditSink.java
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.impl.audit;
|
||||||
|
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Deque;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.PkiId;
|
||||||
|
import zeroecho.pki.api.audit.AuditEvent;
|
||||||
|
import zeroecho.pki.api.audit.AuditQuery;
|
||||||
|
import zeroecho.pki.spi.audit.AuditSink;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory {@link AuditSink} provider intended for deterministic tests and
|
||||||
|
* local debugging.
|
||||||
|
*
|
||||||
|
* <h2>Memory bound</h2>
|
||||||
|
* <p>
|
||||||
|
* This sink is bounded by {@code maxEvents}. When the capacity is exceeded, the
|
||||||
|
* oldest event is dropped (ring-buffer policy). This prevents unbounded memory
|
||||||
|
* growth in long-running processes.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <h3>Configuration</h3>
|
||||||
|
* <ul>
|
||||||
|
* <li>System property {@code zeroecho.pki.audit.inmemory.maxEvents} (positive
|
||||||
|
* integer)</li>
|
||||||
|
* <li>Default: 10_000</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Security properties</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>Does not log event payloads.</li>
|
||||||
|
* <li>Stores events as received; callers must ensure
|
||||||
|
* {@link AuditEvent#details()} contains no secrets.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Thread-safety</h2>
|
||||||
|
* <p>
|
||||||
|
* This implementation is synchronized and safe for concurrent use.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public final class InMemoryAuditSink implements AuditSink, SearchableAuditSink {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(InMemoryAuditSink.class.getName());
|
||||||
|
|
||||||
|
/* default */ static final int DEFAULT_MAX_EVENTS = 10_000;
|
||||||
|
|
||||||
|
private final int maxEvents;
|
||||||
|
private final Deque<AuditEvent> events;
|
||||||
|
|
||||||
|
private boolean overflowLogged;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public no-arg constructor required for {@link java.util.ServiceLoader}.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Defaults to 10_000 events.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public InMemoryAuditSink() {
|
||||||
|
this(DEFAULT_MAX_EVENTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a sink with an explicit maximum number of events.
|
||||||
|
*
|
||||||
|
* @param maxEvents maximum number of retained events (must be positive)
|
||||||
|
* @throws IllegalArgumentException if {@code maxEvents <= 0}
|
||||||
|
*/
|
||||||
|
public InMemoryAuditSink(int maxEvents) {
|
||||||
|
if (maxEvents <= 0) {
|
||||||
|
throw new IllegalArgumentException("maxEvents must be positive");
|
||||||
|
}
|
||||||
|
this.maxEvents = maxEvents;
|
||||||
|
this.events = new ArrayDeque<>(Math.min(maxEvents, 1024));
|
||||||
|
this.overflowLogged = false;
|
||||||
|
|
||||||
|
LOG.log(Level.INFO, "InMemoryAuditSink initialized; maxEvents={0}", maxEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void record(AuditEvent event) { // NOPMD
|
||||||
|
if (event == null) {
|
||||||
|
throw new IllegalArgumentException("event must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.size() >= maxEvents) {
|
||||||
|
events.removeFirst();
|
||||||
|
if (!overflowLogged) {
|
||||||
|
overflowLogged = true;
|
||||||
|
LOG.log(Level.WARNING, "InMemoryAuditSink capacity exceeded; dropping oldest events (maxEvents={0})",
|
||||||
|
maxEvents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events.addLast(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an immutable snapshot of all currently retained events in insertion
|
||||||
|
* order (oldest to newest).
|
||||||
|
*
|
||||||
|
* @return immutable snapshot
|
||||||
|
*/
|
||||||
|
public synchronized List<AuditEvent> snapshot() { // NOPMD
|
||||||
|
return List.copyOf(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized List<AuditEvent> search(AuditQuery query) { // NOPMD
|
||||||
|
if (query == null) {
|
||||||
|
throw new IllegalArgumentException("query must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AuditEvent> out = new ArrayList<>();
|
||||||
|
for (AuditEvent e : events) {
|
||||||
|
if (e.matches(query)) {
|
||||||
|
out.add(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort(AuditEvent.auditOrder());
|
||||||
|
return List.copyOf(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized Optional<AuditEvent> getById(PkiId eventId) { // NOPMD
|
||||||
|
if (eventId == null) {
|
||||||
|
throw new IllegalArgumentException("eventId must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (AuditEvent e : events) {
|
||||||
|
if (AuditEventCodec.eventId(e).equals(eventId)) {
|
||||||
|
return Optional.of(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the configured maximum number of retained events.
|
||||||
|
*
|
||||||
|
* @return maximum retained events
|
||||||
|
*/
|
||||||
|
public int maxEvents() {
|
||||||
|
return maxEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized String toString() { // NOPMD
|
||||||
|
return "InMemoryAuditSink[size=" + events.size() + ", maxEvents=" + maxEvents + "]";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.impl.audit;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import zeroecho.pki.spi.ProviderConfig;
|
||||||
|
import zeroecho.pki.spi.audit.AuditSink;
|
||||||
|
import zeroecho.pki.spi.audit.AuditSinkProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public class InMemoryAuditSinkProvider implements AuditSinkProvider {
|
||||||
|
@Override
|
||||||
|
public String id() {
|
||||||
|
return "memory";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<String> supportedKeys() {
|
||||||
|
return Set.of("size");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AuditSink allocate(ProviderConfig config) {
|
||||||
|
|
||||||
|
Optional<String> sizeStr = config.get("size");
|
||||||
|
if (sizeStr.isPresent()) {
|
||||||
|
return new InMemoryAuditSink(parseIntOrDefault(sizeStr.get(), InMemoryAuditSink.DEFAULT_MAX_EVENTS));
|
||||||
|
} else {
|
||||||
|
return new InMemoryAuditSink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int parseIntOrDefault(String value, int defaultValue) {
|
||||||
|
if (value == null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(value);
|
||||||
|
} catch (NumberFormatException ex) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.impl.audit;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.PkiId;
|
||||||
|
import zeroecho.pki.api.audit.AuditEvent;
|
||||||
|
import zeroecho.pki.api.audit.AuditQuery;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal query extension for audit sinks.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The public SPI {@code zeroecho.pki.spi.AuditSink} is record-only. Sinks may
|
||||||
|
* also implement this internal interface to support
|
||||||
|
* {@link zeroecho.pki.api.audit.AuditService#search(AuditQuery)} and
|
||||||
|
* {@link zeroecho.pki.api.audit.AuditService#get(PkiId)}.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
interface SearchableAuditSink {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches persisted events by query constraints.
|
||||||
|
*
|
||||||
|
* @param query query constraints (must not be null)
|
||||||
|
* @return immutable list of matching events (never null)
|
||||||
|
* @throws IllegalArgumentException if {@code query} is null
|
||||||
|
* @throws RuntimeException on storage failure
|
||||||
|
*/
|
||||||
|
List<AuditEvent> search(AuditQuery query);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a persisted event by stable id.
|
||||||
|
*
|
||||||
|
* @param eventId stable event id (must not be null)
|
||||||
|
* @return event if present
|
||||||
|
* @throws IllegalArgumentException if {@code eventId} is null
|
||||||
|
* @throws RuntimeException on storage failure
|
||||||
|
*/
|
||||||
|
Optional<AuditEvent> getById(PkiId eventId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.impl.audit;
|
||||||
|
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.audit.AuditEvent;
|
||||||
|
import zeroecho.pki.spi.audit.AuditSink;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference {@link AuditSink} provider writing a deterministic summary line to
|
||||||
|
* standard output.
|
||||||
|
*
|
||||||
|
* <h2>Security properties</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>Does not print {@link AuditEvent#details()} to reduce the risk of secret
|
||||||
|
* disclosure.</li>
|
||||||
|
* <li>Only prints high-level metadata (time, category, action, principal,
|
||||||
|
* purpose).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Determinism</h2>
|
||||||
|
* <p>
|
||||||
|
* Timestamp is rendered in ISO-8601 UTC, and the output line layout is stable.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This sink is suitable for development and demonstrations. It is not intended
|
||||||
|
* as a production persistence backend.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public final class StdoutAuditSink implements AuditSink {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TS = DateTimeFormatter.ISO_INSTANT.withZone(ZoneOffset.UTC);
|
||||||
|
private static final Logger LOG = Logger.getLogger(StdoutAuditSink.class.getName());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public no-arg constructor required for {@link java.util.ServiceLoader}.
|
||||||
|
*/
|
||||||
|
public StdoutAuditSink() {
|
||||||
|
LOG.info("initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void record(AuditEvent event) {
|
||||||
|
Objects.requireNonNull(event, "event");
|
||||||
|
|
||||||
|
String line = TS.format(event.time()) + " category=" + safe(event.category()) + " action="
|
||||||
|
+ safe(event.action()) + " principalType=" + safe(event.principal().type()) + " principalName="
|
||||||
|
+ safe(event.principal().name()) + " purpose=" + safe(event.purpose().value());
|
||||||
|
|
||||||
|
System.out.println(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safe(String s) {
|
||||||
|
if (s == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return s.replace('\n', ' ').replace('\r', ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.impl.audit;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import zeroecho.pki.spi.ProviderConfig;
|
||||||
|
import zeroecho.pki.spi.audit.AuditSink;
|
||||||
|
import zeroecho.pki.spi.audit.AuditSinkProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public class StdoutAuditSinkProvider implements AuditSinkProvider {
|
||||||
|
@Override
|
||||||
|
public String id() {
|
||||||
|
return "stdout";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<String> supportedKeys() {
|
||||||
|
return Collections.emptySet();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AuditSink allocate(ProviderConfig config) {
|
||||||
|
return new StdoutAuditSink();
|
||||||
|
}
|
||||||
|
}
|
||||||
72
pki/src/main/java/zeroecho/pki/impl/audit/package-info.java
Normal file
72
pki/src/main/java/zeroecho/pki/impl/audit/package-info.java
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
/**
|
||||||
|
* Audit logging reference implementations for ZeroEcho PKI.
|
||||||
|
*
|
||||||
|
* <h2>Scope</h2>
|
||||||
|
* <p>
|
||||||
|
* This package provides concrete implementations of the audit runtime, based on
|
||||||
|
* the public API {@code zeroecho.pki.api.audit.*} and the SPI
|
||||||
|
* {@code zeroecho.pki.spi.AuditSink}.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <h2>Security properties</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li><strong>No secrets in logs:</strong> JUL logs never include audit payload
|
||||||
|
* maps, attribute values, keys, seeds, plaintext, or other sensitive
|
||||||
|
* material.</li>
|
||||||
|
* <li><strong>No secrets by default:</strong> reference sinks avoid printing
|
||||||
|
* {@link zeroecho.pki.api.audit.AuditEvent#details()} unless explicitly
|
||||||
|
* required by a custom sink implementation.</li>
|
||||||
|
* <li><strong>Fail-fast recording:</strong> sink failures are propagated to the
|
||||||
|
* caller to avoid silent audit loss.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Determinism</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>Search results are deterministically sorted.</li>
|
||||||
|
* <li>Stable event identifiers are derived from canonicalized content
|
||||||
|
* (SHA-256).</li>
|
||||||
|
* <li>File enumeration is deterministic (lexicographic order).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Persistence</h2>
|
||||||
|
* <p>
|
||||||
|
* {@link zeroecho.pki.impl.audit.FileAuditSink} provides a baseline persistent
|
||||||
|
* store using daily gzip files and sequential scanning. It is suitable as an
|
||||||
|
* initial production-grade implementation without introducing a database.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
package zeroecho.pki.impl.audit;
|
||||||
@@ -147,6 +147,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
ensureVersionFile();
|
ensureVersionFile();
|
||||||
this.historySeq = new AtomicLong(0L);
|
this.historySeq = new AtomicLong(0L);
|
||||||
|
|
||||||
|
LOG.log(Level.INFO, "running in {0}", root);
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new IllegalStateException("failed to open filesystem store at " + root, e);
|
throw new IllegalStateException("failed to open filesystem store at " + root, e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.impl.fs;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import zeroecho.pki.spi.ProviderConfig;
|
||||||
|
import zeroecho.pki.spi.audit.AuditSinkProvider;
|
||||||
|
import zeroecho.pki.spi.store.PkiStore;
|
||||||
|
import zeroecho.pki.spi.store.PkiStoreProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link AuditSinkProvider} for the filesystem-backed {@link PkiStore}.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Supported configuration keys:
|
||||||
|
* </p>
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code root} - store root directory (required)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class FilesystemPkiStoreProvider implements PkiStoreProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public no-arg constructor required by {@link java.util.ServiceLoader}.
|
||||||
|
*/
|
||||||
|
public FilesystemPkiStoreProvider() { // NOPMD
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String id() {
|
||||||
|
return "fs";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<String> supportedKeys() {
|
||||||
|
return Set.of("root");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PkiStore allocate(ProviderConfig config) {
|
||||||
|
Objects.requireNonNull(config, "config");
|
||||||
|
|
||||||
|
String rootString = config.require("root");
|
||||||
|
Path root = Path.of(rootString);
|
||||||
|
|
||||||
|
FsPkiStoreOptions options = FsPkiStoreOptions.defaults();
|
||||||
|
return new FilesystemPkiStore(root, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
pki/src/main/java/zeroecho/pki/spi/ConfigurableProvider.java
Normal file
112
pki/src/main/java/zeroecho/pki/spi/ConfigurableProvider.java
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.spi;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServiceLoader provider for configurable implementations.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* {@link java.util.ServiceLoader} instantiates providers using a public no-arg
|
||||||
|
* constructor. However, the <em>produced</em> instances (stores, audit sinks,
|
||||||
|
* publishers, framework adapters) typically require backend-specific
|
||||||
|
* configuration (filesystem root, JDBC URL, remote endpoints, credentials,
|
||||||
|
* etc.). This SPI standardizes that configuration handoff.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <h2>Provider identity</h2>
|
||||||
|
* <p>
|
||||||
|
* {@link #id()} must return a stable identifier that is unique among providers
|
||||||
|
* of the same service type. Identifiers are used for deterministic selection
|
||||||
|
* (e.g. via system properties) and must not change across releases once
|
||||||
|
* published.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <h2>Configuration</h2>
|
||||||
|
* <p>
|
||||||
|
* Configuration is provided as string properties through
|
||||||
|
* {@link ProviderConfig}. Providers must validate required keys and reject
|
||||||
|
* invalid configurations with {@link IllegalArgumentException}. Unknown keys
|
||||||
|
* must be ignored for forward compatibility. {@link #supportedKeys()} is
|
||||||
|
* informational and may be used for diagnostics or help output.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <h2>Security</h2>
|
||||||
|
* <p>
|
||||||
|
* Configuration values may contain sensitive material (passwords, tokens,
|
||||||
|
* endpoints). Providers and bootstraps must never log configuration values. If
|
||||||
|
* logging is required, log provider id and configuration <em>keys only</em>.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param <T> the type produced by the provider (e.g. {@code PkiStore},
|
||||||
|
* {@code AuditSink})
|
||||||
|
*/
|
||||||
|
public interface ConfigurableProvider<T> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a stable backend identifier (e.g. {@code "fs"}, {@code "jdbc"}).
|
||||||
|
*
|
||||||
|
* @return backend identifier (never {@code null}, never blank)
|
||||||
|
*/
|
||||||
|
String id();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the configuration keys understood by this backend.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Keys are informational (diagnostics/validation/help). Unknown keys must be
|
||||||
|
* ignored by implementations for forward compatibility.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @return supported configuration keys (never {@code null})
|
||||||
|
*/
|
||||||
|
Set<String> supportedKeys();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allocates a new instance using the provided configuration.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Implementations must validate required keys and throw
|
||||||
|
* {@link IllegalArgumentException} if configuration is incomplete or invalid.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param config configuration (never {@code null})
|
||||||
|
* @return new instance (never {@code null})
|
||||||
|
* @throws IllegalArgumentException if required configuration is missing/invalid
|
||||||
|
* @throws RuntimeException if opening fails
|
||||||
|
*/
|
||||||
|
T allocate(ProviderConfig config);
|
||||||
|
}
|
||||||
109
pki/src/main/java/zeroecho/pki/spi/ProviderConfig.java
Normal file
109
pki/src/main/java/zeroecho/pki/spi/ProviderConfig.java
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.spi;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend configuration for {@link ConfigurableProvider} instances.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Values are stringly-typed by design to keep the SPI generic and stable across
|
||||||
|
* different backends (filesystem, JDBC, remote services). Providers interpret
|
||||||
|
* keys relevant to their backend and must ignore unknown keys for forward
|
||||||
|
* compatibility.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The {@code backendId} is the selected provider identifier
|
||||||
|
* ({@link ConfigurableProvider#id()}), used for diagnostics and selection
|
||||||
|
* correlation. It is not a URL, driver name, nor any secret.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This record is immutable and safe to share. Configuration values may be
|
||||||
|
* sensitive and must not be logged. If diagnostics are needed, log keys only.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param backendId selected backend id (never blank)
|
||||||
|
* @param properties backend properties (never {@code null})
|
||||||
|
*/
|
||||||
|
public record ProviderConfig(String backendId, Map<String, String> properties) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical constructor with validation and defensive wrapping.
|
||||||
|
*
|
||||||
|
* @param backendId backend id (never blank)
|
||||||
|
* @param properties properties (never {@code null})
|
||||||
|
*/
|
||||||
|
public ProviderConfig {
|
||||||
|
Objects.requireNonNull(backendId, "backendId");
|
||||||
|
Objects.requireNonNull(properties, "properties");
|
||||||
|
if (backendId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("backendId must not be blank");
|
||||||
|
}
|
||||||
|
properties = Collections.unmodifiableMap(properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an optional value for the given key.
|
||||||
|
*
|
||||||
|
* @param key key (never {@code null})
|
||||||
|
* @return optional value (never {@code null})
|
||||||
|
*/
|
||||||
|
public Optional<String> get(String key) {
|
||||||
|
Objects.requireNonNull(key, "key");
|
||||||
|
return Optional.ofNullable(properties.get(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a required non-blank value for the given key.
|
||||||
|
*
|
||||||
|
* @param key key (never {@code null})
|
||||||
|
* @return value (never {@code null}, never blank)
|
||||||
|
* @throws IllegalArgumentException if missing or blank
|
||||||
|
*/
|
||||||
|
public String require(String key) {
|
||||||
|
Objects.requireNonNull(key, "key");
|
||||||
|
String v = properties.get(key);
|
||||||
|
if (v == null || v.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Missing required config key: " + key);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
* POSSIBILITY OF SUCH DAMAGE.
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
package zeroecho.pki.spi;
|
package zeroecho.pki.spi.audit;
|
||||||
|
|
||||||
import zeroecho.pki.api.audit.AuditEvent;
|
import zeroecho.pki.api.audit.AuditEvent;
|
||||||
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.spi.audit;
|
||||||
|
|
||||||
|
import zeroecho.pki.spi.ConfigurableProvider;
|
||||||
|
import zeroecho.pki.spi.ProviderConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServiceLoader provider for {@link AuditSink} implementations.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* {@link java.util.ServiceLoader} cannot construct {@link AuditSink} instances
|
||||||
|
* directly because stores typically require backend-specific configuration
|
||||||
|
* (filesystem root, etc.). This SPI provides a uniform instantiation mechanism.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Implementations must provide a public no-arg constructor to be discoverable
|
||||||
|
* via {@link java.util.ServiceLoader}.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Security note: providers and bootstraps must never log configuration values
|
||||||
|
* because they can contain sensitive material (passwords, tokens, endpoints).
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public interface AuditSinkProvider extends ConfigurableProvider<AuditSink> {
|
||||||
|
/**
|
||||||
|
* Opens a new {@link AuditSink} instance using the provided configuration.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Implementations must validate required keys and throw
|
||||||
|
* {@link IllegalArgumentException} if configuration is incomplete or invalid.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param config configuration (never {@code null})
|
||||||
|
* @return opened store (never {@code null})
|
||||||
|
* @throws IllegalArgumentException if required configuration is missing/invalid
|
||||||
|
* @throws RuntimeException if opening fails
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
AuditSink allocate(ProviderConfig config);
|
||||||
|
}
|
||||||
116
pki/src/main/java/zeroecho/pki/spi/audit/package-info.java
Normal file
116
pki/src/main/java/zeroecho/pki/spi/audit/package-info.java
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
/**
|
||||||
|
* Service Provider Interfaces (SPI) for PKI audit event persistence.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This package defines the SPI contracts that allow the PKI subsystem to
|
||||||
|
* delegate audit event persistence to pluggable, backend-specific
|
||||||
|
* implementations discovered via {@link java.util.ServiceLoader}.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <h2>Purpose and scope</h2>
|
||||||
|
* <p>
|
||||||
|
* Audit functionality in the PKI layer is intentionally decoupled from any
|
||||||
|
* concrete storage mechanism. Implementations may persist audit events to
|
||||||
|
* filesystems, databases, remote services, or in-memory buffers, depending on
|
||||||
|
* deployment requirements.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The SPI is split into two roles:
|
||||||
|
* </p>
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link zeroecho.pki.spi.audit.AuditSink} – a runtime component that
|
||||||
|
* receives {@link zeroecho.pki.api.audit.AuditEvent audit events} and persists
|
||||||
|
* them.</li>
|
||||||
|
* <li>{@link zeroecho.pki.spi.audit.AuditSinkProvider} – a
|
||||||
|
* {@link java.util.ServiceLoader}-discoverable factory responsible for creating
|
||||||
|
* and configuring {@code AuditSink} instances.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Instantiation model</h2>
|
||||||
|
* <p>
|
||||||
|
* {@link java.util.ServiceLoader} is used only to discover
|
||||||
|
* {@code AuditSinkProvider} implementations. Direct discovery of
|
||||||
|
* {@code AuditSink} instances is intentionally avoided because most audit
|
||||||
|
* backends require configuration parameters (such as paths, connection strings,
|
||||||
|
* credentials, or limits).
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Providers must expose a public no-argument constructor and create
|
||||||
|
* {@code AuditSink} instances exclusively through the
|
||||||
|
* {@link zeroecho.pki.spi.audit.AuditSinkProvider#allocate allocate} method,
|
||||||
|
* using a {@link zeroecho.pki.spi.ProviderConfig} supplied by the bootstrap or
|
||||||
|
* runtime environment.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <h2>Security and compliance requirements</h2>
|
||||||
|
* <p>
|
||||||
|
* Implementations of this SPI are security-critical. The following rules are
|
||||||
|
* mandatory:
|
||||||
|
* </p>
|
||||||
|
* <ul>
|
||||||
|
* <li>Audit sinks must never persist secrets, private keys, shared secrets,
|
||||||
|
* passwords, tokens, or other sensitive cryptographic material.</li>
|
||||||
|
* <li>Providers and sinks must apply appropriate redaction and minimization
|
||||||
|
* policies to all persisted data.</li>
|
||||||
|
* <li>Configuration values supplied via {@code ProviderConfig} must never be
|
||||||
|
* written to logs or audit outputs, as they may contain sensitive information.
|
||||||
|
* </li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Thread-safety and lifecycle</h2>
|
||||||
|
* <p>
|
||||||
|
* Unless explicitly documented otherwise by a concrete implementation,
|
||||||
|
* {@code AuditSink} instances are expected to be thread-safe and usable by
|
||||||
|
* multiple concurrent PKI operations.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Lifecycle management (creation, activation, shutdown) is controlled by the
|
||||||
|
* PKI bootstrap and runtime layers; this SPI does not define explicit close or
|
||||||
|
* shutdown semantics unless required by a specific implementation.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <h2>Intended usage</h2>
|
||||||
|
* <p>
|
||||||
|
* This package is intended for infrastructure-level integration and must not be
|
||||||
|
* used directly by application code. Consumers should interact with audit
|
||||||
|
* functionality exclusively through the public PKI API.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
package zeroecho.pki.spi.audit;
|
||||||
217
pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java
Normal file
217
pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.spi.bootstrap;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
import zeroecho.pki.spi.ConfigurableProvider;
|
||||||
|
import zeroecho.pki.spi.ProviderConfig;
|
||||||
|
import zeroecho.pki.spi.audit.AuditSink;
|
||||||
|
import zeroecho.pki.spi.audit.AuditSinkProvider;
|
||||||
|
import zeroecho.pki.spi.store.PkiStore;
|
||||||
|
import zeroecho.pki.spi.store.PkiStoreProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PKI bootstrap utilities for ServiceLoader-based components.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This class provides deterministic selection and instantiation rules for
|
||||||
|
* components discovered via {@link java.util.ServiceLoader}. It is designed to
|
||||||
|
* scale as more SPIs are introduced (audit, publish, framework integrations,
|
||||||
|
* etc.).
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <h2>System property conventions</h2>
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>Select store provider: {@code -Dzeroecho.pki.store=<id>}</li>
|
||||||
|
* <li>Configure store provider:
|
||||||
|
* {@code -Dzeroecho.pki.store.<key>=<value>}</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Security note: configuration values may be sensitive and must not be logged.
|
||||||
|
* This bootstrap logs only provider ids and configuration keys.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public final class PkiBootstrap {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(PkiBootstrap.class.getName());
|
||||||
|
|
||||||
|
private static final String PROP_STORE_BACKEND = "zeroecho.pki.store";
|
||||||
|
private static final String PROP_STORE_PREFIX = "zeroecho.pki.store.";
|
||||||
|
|
||||||
|
private static final String PROP_AUDIT_BACKEND = "zeroecho.pki.audit";
|
||||||
|
private static final String PROP_AUDIT_PREFIX = "zeroecho.pki.audit.";
|
||||||
|
|
||||||
|
private PkiBootstrap() {
|
||||||
|
throw new AssertionError("No instances.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a {@link PkiStore} using {@link PkiStoreProvider} discovered via
|
||||||
|
* {@link java.util.ServiceLoader}.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Provider selection is deterministic and fail-fast:
|
||||||
|
* </p>
|
||||||
|
* <ul>
|
||||||
|
* <li>If {@code -Dzeroecho.pki.store=<id>} is specified, the provider
|
||||||
|
* with the matching id is selected.</li>
|
||||||
|
* <li>If no id is specified and exactly one provider exists, that provider is
|
||||||
|
* selected.</li>
|
||||||
|
* <li>If multiple providers exist and no id is specified, bootstrap fails as
|
||||||
|
* configuration is ambiguous.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Configuration properties are read from {@link System#getProperties()} using
|
||||||
|
* the prefix {@code zeroecho.pki.store.}. Values are treated as sensitive and
|
||||||
|
* are never logged; only keys may be logged.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Defaulting rules implemented by this bootstrap (policy, not SPI requirement):
|
||||||
|
* for {@code fs} provider, if {@code root} is not specified, defaults to
|
||||||
|
* {@code "pki-store"} relative to the working directory.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This method is suitable for CLI and server deployments. In DI containers
|
||||||
|
* (Spring, Micronaut, etc.), invoke it from managed components and close the
|
||||||
|
* returned store using the container lifecycle.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @return opened store (never {@code null})
|
||||||
|
* @throws IllegalStateException if provider selection is ambiguous or no
|
||||||
|
* provider is available
|
||||||
|
* @throws IllegalArgumentException if required configuration is missing/invalid
|
||||||
|
* @throws RuntimeException if allocation fails
|
||||||
|
*/
|
||||||
|
|
||||||
|
public static PkiStore openStore() {
|
||||||
|
String requestedId = System.getProperty(PROP_STORE_BACKEND);
|
||||||
|
|
||||||
|
PkiStoreProvider provider = SpiSelector.select(PkiStoreProvider.class, requestedId,
|
||||||
|
new SpiSelector.ProviderId<>() {
|
||||||
|
@Override
|
||||||
|
public String id(PkiStoreProvider p) {
|
||||||
|
return p.id();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, String> props = SpiSystemProperties.readPrefixed(PROP_STORE_PREFIX);
|
||||||
|
|
||||||
|
if ("fs".equals(provider.id()) && !props.containsKey("root")) {
|
||||||
|
props.put("root", Path.of("pki-store").toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
ProviderConfig config = new ProviderConfig(provider.id(), props);
|
||||||
|
|
||||||
|
if (LOG.isLoggable(Level.INFO)) {
|
||||||
|
LOG.info("Selected store provider: " + provider.id() + " (keys: " + props.keySet() + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider.allocate(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs provider help information (supported keys) for diagnostics.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This method is safe: it does not log configuration values.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param provider provider (never {@code null})
|
||||||
|
*/
|
||||||
|
public static <T> void logSupportedKeys(ConfigurableProvider<T> provider) {
|
||||||
|
Objects.requireNonNull(provider, "provider");
|
||||||
|
if (LOG.isLoggable(Level.FINE)) {
|
||||||
|
LOG.fine("Provider '" + provider.id() + "' supports keys: " + provider.supportedKeys());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens an {@link AuditSink} using {@link AuditSinkProvider} discovered via
|
||||||
|
* {@link java.util.ServiceLoader}.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Selection and configuration follow the same conventions as
|
||||||
|
* {@link #openStore()}, using {@code -Dzeroecho.pki.audit=<id>} and
|
||||||
|
* {@code zeroecho.pki.audit.} prefixed properties.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @return opened audit sink (never {@code null})
|
||||||
|
* @throws IllegalStateException if provider selection is ambiguous or no
|
||||||
|
* provider is available
|
||||||
|
* @throws IllegalArgumentException if required configuration is missing/invalid
|
||||||
|
* @throws RuntimeException if allocation fails
|
||||||
|
*/
|
||||||
|
public static AuditSink openAudit() {
|
||||||
|
String requestedId = System.getProperty(PROP_AUDIT_BACKEND);
|
||||||
|
|
||||||
|
if (requestedId == null) {
|
||||||
|
requestedId = "stdout";
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditSinkProvider provider = SpiSelector.select(AuditSinkProvider.class, requestedId,
|
||||||
|
new SpiSelector.ProviderId<>() {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String id(AuditSinkProvider p) {
|
||||||
|
return p.id();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, String> props = SpiSystemProperties.readPrefixed(PROP_AUDIT_PREFIX);
|
||||||
|
|
||||||
|
if ("file".equals(provider.id()) && !props.containsKey("root")) {
|
||||||
|
props.put("root", Path.of("pki-store").toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
ProviderConfig config = new ProviderConfig(provider.id(), props);
|
||||||
|
|
||||||
|
if (LOG.isLoggable(Level.INFO)) {
|
||||||
|
LOG.info("Selected store provider: " + provider.id() + " (keys: " + props.keySet() + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider.allocate(config);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
142
pki/src/main/java/zeroecho/pki/spi/bootstrap/SpiSelector.java
Normal file
142
pki/src/main/java/zeroecho/pki/spi/bootstrap/SpiSelector.java
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.spi.bootstrap;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.ServiceLoader;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility for selecting ServiceLoader providers by a stable identifier.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This class centralizes deterministic provider-selection rules:
|
||||||
|
* </p>
|
||||||
|
* <ul>
|
||||||
|
* <li>If an id is requested and exactly one provider matches it, that provider
|
||||||
|
* is selected.</li>
|
||||||
|
* <li>If no id is requested and exactly one provider exists, it is
|
||||||
|
* selected.</li>
|
||||||
|
* <li>Otherwise selection fails fast with a descriptive exception.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This utility is intended to be used by multiple bootstraps (store, audit,
|
||||||
|
* publish, framework components). It is deliberately small and has no logging
|
||||||
|
* side-effects.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Note: provider iteration order from {@link java.util.ServiceLoader} must not
|
||||||
|
* be relied upon. Determinism is achieved by explicit id selection or by
|
||||||
|
* requiring a single available provider.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public final class SpiSelector {
|
||||||
|
|
||||||
|
private SpiSelector() {
|
||||||
|
throw new AssertionError("No instances.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider id accessor used by the selector.
|
||||||
|
*
|
||||||
|
* @param <T> provider type
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface ProviderId<T> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the stable id for a provider instance.
|
||||||
|
*
|
||||||
|
* @param provider provider instance (never {@code null})
|
||||||
|
* @return stable id (never {@code null})
|
||||||
|
*/
|
||||||
|
String id(T provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects a provider for the given service type.
|
||||||
|
*
|
||||||
|
* @param <T> provider type
|
||||||
|
* @param service service class (never {@code null})
|
||||||
|
* @param requestedId requested id (may be {@code null})
|
||||||
|
* @param idAccessor id accessor (never {@code null})
|
||||||
|
* @return selected provider (never {@code null})
|
||||||
|
* @throws IllegalStateException if no providers exist, selection is ambiguous,
|
||||||
|
* or id not found
|
||||||
|
*/
|
||||||
|
public static <T> T select(Class<T> service, String requestedId, ProviderId<T> idAccessor) {
|
||||||
|
Objects.requireNonNull(service, "service");
|
||||||
|
Objects.requireNonNull(idAccessor, "idAccessor");
|
||||||
|
|
||||||
|
ServiceLoader<T> loader = ServiceLoader.load(service);
|
||||||
|
List<T> providers = new ArrayList<>();
|
||||||
|
for (T p : loader) {
|
||||||
|
providers.add(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providers.isEmpty()) {
|
||||||
|
throw new IllegalStateException("No providers found for service: " + service.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedId != null) {
|
||||||
|
List<T> matches = new ArrayList<>();
|
||||||
|
for (T p : providers) {
|
||||||
|
String id = idAccessor.id(p);
|
||||||
|
if (requestedId.equals(id)) {
|
||||||
|
matches.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matches.isEmpty()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"No provider found for service " + service.getName() + " with id '" + requestedId + "'.");
|
||||||
|
}
|
||||||
|
if (matches.size() > 1) { // NOPMD
|
||||||
|
throw new IllegalStateException("Multiple providers found for service " + service.getName()
|
||||||
|
+ " with id '" + requestedId + "'.");
|
||||||
|
}
|
||||||
|
return matches.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providers.size() == 1) { // NOPMD
|
||||||
|
return providers.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Multiple providers found for service " + service.getName() + "; specify explicit selection.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.spi.bootstrap;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads component configuration from system properties using a prefix
|
||||||
|
* convention.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Convention:
|
||||||
|
* </p>
|
||||||
|
* <ul>
|
||||||
|
* <li>Select provider: {@code -Dzeroecho.pki.<component>=<id>}</li>
|
||||||
|
* <li>Provider config:
|
||||||
|
* {@code -Dzeroecho.pki.<component>.<key>=<value>}</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Values may be sensitive and must not be logged. Callers may log keys only.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public final class SpiSystemProperties {
|
||||||
|
|
||||||
|
private SpiSystemProperties() {
|
||||||
|
throw new AssertionError("No instances.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads all string system properties with the given prefix.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The returned map is a snapshot of {@link System#getProperties()} at the time
|
||||||
|
* of invocation. Values are returned as-is (no trimming/normalization) to keep
|
||||||
|
* behavior explicit.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param prefix property prefix including trailing dot, e.g.
|
||||||
|
* {@code "zeroecho.pki.store."}
|
||||||
|
* @return map of subKey -> value (never {@code null})
|
||||||
|
*/
|
||||||
|
public static Map<String, String> readPrefixed(String prefix) {
|
||||||
|
Objects.requireNonNull(prefix, "prefix");
|
||||||
|
|
||||||
|
Map<String, String> out = new HashMap<>();
|
||||||
|
Properties props = System.getProperties();
|
||||||
|
for (Map.Entry<Object, Object> e : props.entrySet()) {
|
||||||
|
Object k = e.getKey();
|
||||||
|
Object v = e.getValue();
|
||||||
|
if (!(k instanceof String) || !(v instanceof String)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = (String) k;
|
||||||
|
if (!key.startsWith(prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String subKey = key.substring(prefix.length());
|
||||||
|
if (subKey.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.put(subKey, (String) v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
/**
|
||||||
|
* Bootstrap utilities for ServiceLoader-based components in the PKI module.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This package centralizes deterministic provider selection and a minimal
|
||||||
|
* configuration convention for multiple SPIs (store, audit, publication, and
|
||||||
|
* future components).
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Selection is fail-fast: if multiple providers exist for a service and no
|
||||||
|
* explicit provider id is requested, bootstrap rejects the configuration as
|
||||||
|
* ambiguous.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The conventions are intentionally minimal:
|
||||||
|
* </p>
|
||||||
|
* <ul>
|
||||||
|
* <li>Select provider: {@code -Dzeroecho.pki.<component>=<id>}</li>
|
||||||
|
* <li>Provider properties:
|
||||||
|
* {@code -Dzeroecho.pki.<component>.<key>=<value>}</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* These utilities can be used both in CLI and server deployments. In DI
|
||||||
|
* containers (Spring, Micronaut, etc.), you may invoke the bootstrap from
|
||||||
|
* managed beans while using container lifecycle to close resources.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Security note: configuration values may contain sensitive material and must
|
||||||
|
* not be logged. Bootstraps should log provider ids and configuration keys
|
||||||
|
* only.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
package zeroecho.pki.spi.bootstrap;
|
||||||
@@ -36,9 +36,22 @@
|
|||||||
* Service Provider Interfaces (SPIs) for the PKI module.
|
* Service Provider Interfaces (SPIs) for the PKI module.
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* SPIs are used to plug in persistence, audit sinks, publishing targets, and
|
* SPIs are used to plug in persistence backends, audit sinks, publishing
|
||||||
* credential framework implementations. Public API types remain
|
* targets, and framework integrations while keeping the public PKI API
|
||||||
* framework-agnostic.
|
* framework-agnostic. Implementations are typically discovered via
|
||||||
|
* {@link java.util.ServiceLoader}.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The SPI layer is suitable both for CLI and server deployments. In DI
|
||||||
|
* containers (Spring, Micronaut, etc.), ServiceLoader discovery can be invoked
|
||||||
|
* from managed beans, while lifecycle is controlled by the container.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Backend configuration is passed as string properties through
|
||||||
|
* {@link zeroecho.pki.spi.ProviderConfig}. Configuration values may be
|
||||||
|
* sensitive and must not be logged.
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
package zeroecho.pki.spi;
|
package zeroecho.pki.spi;
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.spi.store;
|
||||||
|
|
||||||
|
import zeroecho.pki.spi.ConfigurableProvider;
|
||||||
|
import zeroecho.pki.spi.ProviderConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link java.util.ServiceLoader} provider for {@link PkiStore}
|
||||||
|
* implementations.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This interface is a PKI-specific specialization of
|
||||||
|
* {@link ConfigurableProvider} for the persistence backend. See
|
||||||
|
* {@link ConfigurableProvider} and {@link ProviderConfig} for the generic
|
||||||
|
* provider and configuration contract.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Implementations must be discoverable via {@link java.util.ServiceLoader} and
|
||||||
|
* therefore must provide a public no-arg constructor.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Security note: configuration values may contain sensitive material. Providers
|
||||||
|
* must not log configuration values.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public interface PkiStoreProvider extends ConfigurableProvider<PkiStore> {
|
||||||
|
/**
|
||||||
|
* Opens a new {@link PkiStore} instance using the provided configuration.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Implementations must validate required keys and throw
|
||||||
|
* {@link IllegalArgumentException} if configuration is incomplete or invalid.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param config configuration (never {@code null})
|
||||||
|
* @return opened store (never {@code null})
|
||||||
|
* @throws IllegalArgumentException if required configuration is missing/invalid
|
||||||
|
* @throws RuntimeException if opening fails
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
PkiStore allocate(ProviderConfig config);
|
||||||
|
}
|
||||||
@@ -36,9 +36,23 @@
|
|||||||
* Persistence SPI for PKI state.
|
* Persistence SPI for PKI state.
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* Implementations persist CA entities, credentials, requests, revocations,
|
* This package defines the authoritative persistence contract for PKI-managed
|
||||||
* status objects, profiles, policy traces, publication records, and optionally
|
* state (CAs, credentials, requests, revocations, status objects, profiles,
|
||||||
* audit events. Storage must be deterministic and suitable for backup/restore.
|
* publications, and policy traces). Storage must be deterministic and suitable
|
||||||
|
* for backup/restore.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Concrete persistence backends are instantiated via
|
||||||
|
* {@link java.util.ServiceLoader} using
|
||||||
|
* {@link zeroecho.pki.spi.store.PkiStoreProvider}. Backend configuration is
|
||||||
|
* provided through {@link zeroecho.pki.spi.ProviderConfig}.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Security note: private key material must not be persisted. Configuration
|
||||||
|
* values may be sensitive (passwords/tokens/endpoints) and must not be logged
|
||||||
|
* by providers or bootstraps.
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
package zeroecho.pki.spi.store;
|
package zeroecho.pki.spi.store;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
zeroecho.pki.impl.audit.FileAuditSinkProvider
|
||||||
|
zeroecho.pki.impl.audit.InMemoryAuditSinkProvider
|
||||||
|
zeroecho.pki.impl.audit.StdoutAuditSinkProvider
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
zeroecho.pki.impl.fs.FilesystemPkiStoreProvider
|
||||||
113
pki/src/test/java/zeroecho/pki/impl/audit/FileAuditSinkTest.java
Normal file
113
pki/src/test/java/zeroecho/pki/impl/audit/FileAuditSinkTest.java
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.impl.audit;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.FormatId;
|
||||||
|
import zeroecho.pki.api.PkiId;
|
||||||
|
import zeroecho.pki.api.audit.AuditEvent;
|
||||||
|
import zeroecho.pki.api.audit.AuditQuery;
|
||||||
|
import zeroecho.pki.api.audit.Principal;
|
||||||
|
import zeroecho.pki.api.audit.Purpose;
|
||||||
|
|
||||||
|
public final class FileAuditSinkTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
public Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void recordThenSearchAndGetById_roundTripsDeterministically() throws Exception {
|
||||||
|
System.out.println("recordThenSearchAndGetById_roundTripsDeterministically");
|
||||||
|
|
||||||
|
// Use JUnit-managed temp directory.
|
||||||
|
Path dir = tempDir.resolve("audit");
|
||||||
|
System.out.println("...dir=" + dir.toAbsolutePath());
|
||||||
|
|
||||||
|
FileAuditSink sink = new FileAuditSink(dir);
|
||||||
|
|
||||||
|
Principal principal = new Principal("USER", "alice");
|
||||||
|
Purpose purpose = new Purpose("LDAP");
|
||||||
|
|
||||||
|
AuditEvent e1 = new AuditEvent(Instant.parse("2025-01-01T00:00:00Z"), "CAT", "READ", principal, purpose,
|
||||||
|
Optional.of(new PkiId("obj-1")), Optional.of(new FormatId("x509")), Map.of("decision", "ALLOW"));
|
||||||
|
|
||||||
|
AuditEvent e2 = new AuditEvent(Instant.parse("2025-01-01T00:00:01Z"), "CAT", "WRITE", principal, purpose,
|
||||||
|
Optional.of(new PkiId("obj-1")), Optional.of(new FormatId("x509")), Map.of("decision", "DENY"));
|
||||||
|
|
||||||
|
// Insert in reverse order to validate deterministic query ordering.
|
||||||
|
sink.record(e2);
|
||||||
|
sink.record(e1);
|
||||||
|
|
||||||
|
AuditQuery q = new AuditQuery(Optional.of("CAT"), Optional.empty(), Optional.empty(), Optional.empty(),
|
||||||
|
Optional.of(new PkiId("obj-1")), Optional.of("alice"));
|
||||||
|
|
||||||
|
List<AuditEvent> found = sink.search(q);
|
||||||
|
System.out.println("...foundSize=" + found.size());
|
||||||
|
|
||||||
|
assertEquals(2, found.size());
|
||||||
|
assertEquals("READ", found.get(0).action());
|
||||||
|
assertEquals("WRITE", found.get(1).action());
|
||||||
|
|
||||||
|
PkiId id = AuditEventCodec.eventId(e2);
|
||||||
|
System.out.println("...eventId=" + summarize(id.value(), 16));
|
||||||
|
|
||||||
|
Optional<AuditEvent> loaded = sink.getById(id);
|
||||||
|
assertTrue(loaded.isPresent());
|
||||||
|
assertEquals("WRITE", loaded.get().action());
|
||||||
|
|
||||||
|
System.out.println("...ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String summarize(String s, int prefixLen) {
|
||||||
|
if (s == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (s.length() <= prefixLen) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return s.substring(0, prefixLen) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.impl.audit;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.PkiId;
|
||||||
|
import zeroecho.pki.api.audit.AuditEvent;
|
||||||
|
import zeroecho.pki.api.audit.AuditQuery;
|
||||||
|
import zeroecho.pki.api.audit.Principal;
|
||||||
|
import zeroecho.pki.api.audit.Purpose;
|
||||||
|
|
||||||
|
public final class InMemoryAuditSinkTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void recordAndSnapshot_preservesInsertionOrder() {
|
||||||
|
System.out.println("recordAndSnapshot_preservesInsertionOrder");
|
||||||
|
|
||||||
|
InMemoryAuditSink sink = new InMemoryAuditSink();
|
||||||
|
|
||||||
|
AuditEvent e1 = new AuditEvent(Instant.parse("2025-01-01T00:00:01Z"), "CAT", "A",
|
||||||
|
new Principal("USER", "alice"), new Purpose("LDAP"), Optional.empty(), Optional.empty(),
|
||||||
|
Map.of("k", "v"));
|
||||||
|
|
||||||
|
AuditEvent e2 = new AuditEvent(Instant.parse("2025-01-01T00:00:02Z"), "CAT", "B",
|
||||||
|
new Principal("USER", "alice"), new Purpose("LDAP"), Optional.empty(), Optional.empty(),
|
||||||
|
Map.of("k", "v"));
|
||||||
|
|
||||||
|
sink.record(e1);
|
||||||
|
sink.record(e2);
|
||||||
|
|
||||||
|
List<AuditEvent> snap = sink.snapshot();
|
||||||
|
|
||||||
|
System.out.println("...snapshotSize=" + snap.size());
|
||||||
|
assertEquals(2, snap.size());
|
||||||
|
assertEquals("A", snap.get(0).action());
|
||||||
|
assertEquals("B", snap.get(1).action());
|
||||||
|
|
||||||
|
System.out.println("...ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void search_returnsDeterministicallySortedResults() {
|
||||||
|
System.out.println("search_returnsDeterministicallySortedResults");
|
||||||
|
|
||||||
|
InMemoryAuditSink sink = new InMemoryAuditSink();
|
||||||
|
|
||||||
|
Principal principal = new Principal("USER", "alice");
|
||||||
|
Purpose purpose = new Purpose("LDAP");
|
||||||
|
|
||||||
|
AuditEvent late = new AuditEvent(Instant.parse("2025-01-01T00:00:02Z"), "CAT", "WRITE", principal, purpose,
|
||||||
|
Optional.empty(), Optional.empty(), Map.of("k", "v"));
|
||||||
|
|
||||||
|
AuditEvent early = new AuditEvent(Instant.parse("2025-01-01T00:00:01Z"), "CAT", "READ", principal, purpose,
|
||||||
|
Optional.empty(), Optional.empty(), Map.of("k", "v"));
|
||||||
|
|
||||||
|
// Insert in reverse order to validate deterministic sort.
|
||||||
|
sink.record(late);
|
||||||
|
sink.record(early);
|
||||||
|
|
||||||
|
AuditQuery query = new AuditQuery(Optional.of("CAT"), Optional.empty(), Optional.empty(), Optional.empty(),
|
||||||
|
Optional.empty(), Optional.of("alice"));
|
||||||
|
|
||||||
|
List<AuditEvent> found = sink.search(query);
|
||||||
|
|
||||||
|
System.out.println("...foundSize=" + found.size());
|
||||||
|
assertEquals(2, found.size());
|
||||||
|
assertEquals("READ", found.get(0).action());
|
||||||
|
assertEquals("WRITE", found.get(1).action());
|
||||||
|
|
||||||
|
System.out.println("...ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void getById_findsEventByStableId() {
|
||||||
|
System.out.println("getById_findsEventByStableId");
|
||||||
|
|
||||||
|
InMemoryAuditSink sink = new InMemoryAuditSink();
|
||||||
|
|
||||||
|
AuditEvent e = new AuditEvent(Instant.parse("2025-01-01T00:00:00Z"), "CAT", "READ",
|
||||||
|
new Principal("USER", "alice"), new Purpose("LDAP"), Optional.empty(), Optional.empty(),
|
||||||
|
Map.of("k", "v"));
|
||||||
|
|
||||||
|
sink.record(e);
|
||||||
|
|
||||||
|
PkiId id = AuditEventCodec.eventId(e);
|
||||||
|
System.out.println("...eventId=" + summarize(id.value(), 16));
|
||||||
|
|
||||||
|
Optional<AuditEvent> loaded = sink.getById(id);
|
||||||
|
assertTrue(loaded.isPresent());
|
||||||
|
assertEquals("READ", loaded.get().action());
|
||||||
|
|
||||||
|
System.out.println("...ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void record_overCapacity_dropsOldestDeterministically() {
|
||||||
|
System.out.println("record_overCapacity_dropsOldestDeterministically");
|
||||||
|
|
||||||
|
InMemoryAuditSink sink = new InMemoryAuditSink(2);
|
||||||
|
|
||||||
|
AuditEvent e1 = new AuditEvent(Instant.parse("2025-01-01T00:00:01Z"), "CAT", "A",
|
||||||
|
new Principal("USER", "alice"), new Purpose("LDAP"), Optional.empty(), Optional.empty(),
|
||||||
|
Map.of("k", "v"));
|
||||||
|
|
||||||
|
AuditEvent e2 = new AuditEvent(Instant.parse("2025-01-01T00:00:02Z"), "CAT", "B",
|
||||||
|
new Principal("USER", "alice"), new Purpose("LDAP"), Optional.empty(), Optional.empty(),
|
||||||
|
Map.of("k", "v"));
|
||||||
|
|
||||||
|
AuditEvent e3 = new AuditEvent(Instant.parse("2025-01-01T00:00:03Z"), "CAT", "C",
|
||||||
|
new Principal("USER", "alice"), new Purpose("LDAP"), Optional.empty(), Optional.empty(),
|
||||||
|
Map.of("k", "v"));
|
||||||
|
|
||||||
|
sink.record(e1);
|
||||||
|
sink.record(e2);
|
||||||
|
sink.record(e3);
|
||||||
|
|
||||||
|
List<AuditEvent> snap = sink.snapshot();
|
||||||
|
System.out.println("...snapshotSize=" + snap.size());
|
||||||
|
|
||||||
|
assertEquals(2, snap.size());
|
||||||
|
assertEquals("B", snap.get(0).action());
|
||||||
|
assertEquals("C", snap.get(1).action());
|
||||||
|
|
||||||
|
System.out.println("...ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String summarize(String s, int prefixLen) {
|
||||||
|
if (s == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (s.length() <= prefixLen) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return s.substring(0, prefixLen) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.impl.audit;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.audit.AuditEvent;
|
||||||
|
import zeroecho.pki.api.audit.Principal;
|
||||||
|
import zeroecho.pki.api.audit.Purpose;
|
||||||
|
|
||||||
|
public final class StdoutAuditSinkTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void record_printsDeterministicSummaryWithoutDetails() {
|
||||||
|
System.out.println("record_printsDeterministicSummaryWithoutDetails");
|
||||||
|
|
||||||
|
PrintStream originalOut = System.out;
|
||||||
|
|
||||||
|
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||||
|
PrintStream capture = new PrintStream(buffer, true, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
try {
|
||||||
|
System.setOut(capture);
|
||||||
|
|
||||||
|
StdoutAuditSink sink = new StdoutAuditSink();
|
||||||
|
|
||||||
|
AuditEvent event = new AuditEvent(Instant.parse("2025-01-01T00:00:00Z"), "CAT", "READ",
|
||||||
|
new Principal("USER", "alice"), new Purpose("LDAP"), Optional.empty(), Optional.empty(),
|
||||||
|
Map.of("secret", "SHOULD_NOT_APPEAR"));
|
||||||
|
|
||||||
|
sink.record(event);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
System.setOut(originalOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
String out = buffer.toString(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
System.out.println("...stdout=" + summarize(out, 120));
|
||||||
|
|
||||||
|
assertTrue(out.contains("2025-01-01T00:00:00Z"));
|
||||||
|
assertTrue(out.contains("category=CAT"));
|
||||||
|
assertTrue(out.contains("action=READ"));
|
||||||
|
assertTrue(out.contains("principalType=USER"));
|
||||||
|
assertTrue(out.contains("principalName=alice"));
|
||||||
|
assertTrue(out.contains("purpose=LDAP"));
|
||||||
|
|
||||||
|
// Must not contain details payload.
|
||||||
|
assertTrue(!out.contains("secret"));
|
||||||
|
assertEquals(1, countLines(out));
|
||||||
|
|
||||||
|
System.out.println("...ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int countLines(String s) {
|
||||||
|
int lines = 0;
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
if (s.charAt(i) == '\n') {
|
||||||
|
lines++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If output doesn't end with newline, still treat it as one line if non-empty.
|
||||||
|
if (!s.isEmpty() && s.charAt(s.length() - 1) != '\n') {
|
||||||
|
lines++;
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String summarize(String s, int limit) {
|
||||||
|
String trimmed = s.replace("\r", "");
|
||||||
|
if (trimmed.length() <= limit) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return trimmed.substring(0, limit) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2025, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation
|
||||||
|
* and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. All advertising materials mentioning features or use of this software must
|
||||||
|
* display the following acknowledgement:
|
||||||
|
* This product includes software developed by the Egothor project.
|
||||||
|
*
|
||||||
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software
|
||||||
|
* without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
* POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.spi.bootstrap;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import zeroecho.pki.spi.audit.AuditSink;
|
||||||
|
import zeroecho.pki.spi.store.PkiStore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JUnit 5 tests for {@link PkiBootstrap}.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The tests validate provider selection and configuration conventions based on
|
||||||
|
* {@link java.util.ServiceLoader} providers and {@link System#getProperties()}.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Security note: tests must not print configuration values that might be
|
||||||
|
* sensitive. Here we only print safe information (provider ids, class names,
|
||||||
|
* and key presence).
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public final class PkiBootstrapTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
private Path tempDir;
|
||||||
|
|
||||||
|
private Properties savedProperties;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
public void beforeEach() {
|
||||||
|
System.out.println("beforeEach");
|
||||||
|
this.savedProperties = (Properties) System.getProperties().clone();
|
||||||
|
|
||||||
|
clearPrefix("zeroecho.pki.store.");
|
||||||
|
clearPrefix("zeroecho.pki.audit.");
|
||||||
|
|
||||||
|
System.clearProperty("zeroecho.pki.store");
|
||||||
|
System.clearProperty("zeroecho.pki.audit");
|
||||||
|
|
||||||
|
System.out.println("...tempDir=" + this.tempDir);
|
||||||
|
System.out.println("...ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
public void afterEach() {
|
||||||
|
System.out.println("afterEach");
|
||||||
|
System.setProperties(this.savedProperties);
|
||||||
|
System.out.println("...ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void openStore_fs_usesTempRoot() {
|
||||||
|
System.out.println("openStore_fs_usesTempRoot");
|
||||||
|
|
||||||
|
System.setProperty("zeroecho.pki.store", "fs");
|
||||||
|
System.setProperty("zeroecho.pki.store.root", this.tempDir.resolve("store").toString());
|
||||||
|
|
||||||
|
PkiStore store = PkiBootstrap.openStore();
|
||||||
|
assertNotNull(store);
|
||||||
|
|
||||||
|
String storeClassName = store.getClass().getName();
|
||||||
|
System.out.println("...storeClass=" + storeClassName);
|
||||||
|
|
||||||
|
// Do not rely on implementation type at compile time; compare by name.
|
||||||
|
assertEquals("zeroecho.pki.impl.fs.FilesystemPkiStore", storeClassName);
|
||||||
|
|
||||||
|
System.out.println("...ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void openAudit_defaultIsStdout() {
|
||||||
|
System.out.println("openAudit_defaultIsStdout");
|
||||||
|
|
||||||
|
// Per bootstrap: if no -Dzeroecho.pki.audit is set, default is "stdout".
|
||||||
|
// This must work even if multiple audit providers exist.
|
||||||
|
// :contentReference[oaicite:1]{index=1}
|
||||||
|
AuditSink sink = PkiBootstrap.openAudit();
|
||||||
|
assertNotNull(sink);
|
||||||
|
|
||||||
|
String sinkClassName = sink.getClass().getName();
|
||||||
|
System.out.println("...auditClass=" + sinkClassName);
|
||||||
|
|
||||||
|
assertEquals("zeroecho.pki.impl.audit.StdoutAuditSink", sinkClassName);
|
||||||
|
|
||||||
|
System.out.println("...ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void openAudit_file_usesTempRoot() {
|
||||||
|
System.out.println("openAudit_file_usesTempRoot");
|
||||||
|
|
||||||
|
System.setProperty("zeroecho.pki.audit", "file");
|
||||||
|
System.setProperty("zeroecho.pki.audit.root", this.tempDir.resolve("audit").toString());
|
||||||
|
|
||||||
|
AuditSink sink = PkiBootstrap.openAudit();
|
||||||
|
assertNotNull(sink);
|
||||||
|
|
||||||
|
String sinkClassName = sink.getClass().getName();
|
||||||
|
System.out.println("...auditClass=" + sinkClassName);
|
||||||
|
|
||||||
|
assertEquals("zeroecho.pki.impl.audit.FileAuditSink", sinkClassName);
|
||||||
|
|
||||||
|
System.out.println("...ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void openAudit_memory_acceptsSize() {
|
||||||
|
System.out.println("openAudit_memory_acceptsSize");
|
||||||
|
|
||||||
|
System.setProperty("zeroecho.pki.audit", "memory");
|
||||||
|
System.setProperty("zeroecho.pki.audit.size", "123");
|
||||||
|
|
||||||
|
AuditSink sink = PkiBootstrap.openAudit();
|
||||||
|
assertNotNull(sink);
|
||||||
|
|
||||||
|
String sinkClassName = sink.getClass().getName();
|
||||||
|
System.out.println("...auditClass=" + sinkClassName);
|
||||||
|
|
||||||
|
assertEquals("zeroecho.pki.impl.audit.InMemoryAuditSink", sinkClassName);
|
||||||
|
|
||||||
|
System.out.println("...ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void clearPrefix(String prefix) {
|
||||||
|
Properties props = System.getProperties();
|
||||||
|
for (Object keyObj : props.keySet().toArray()) {
|
||||||
|
if (keyObj instanceof String) {
|
||||||
|
String key = (String) keyObj;
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
System.clearProperty(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user