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"/>
|
||||
</attributes>
|
||||
</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">
|
||||
<attributes>
|
||||
<attribute name="test" value="true"/>
|
||||
<attribute name="gradle_scope" value="test"/>
|
||||
<attribute name="gradle_used_by_scope" value="test"/>
|
||||
<attribute name="test" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="src" output="bin/test" path="src/test/resources">
|
||||
<attributes>
|
||||
<attribute name="test" value="true"/>
|
||||
<attribute name="gradle_scope" value="test"/>
|
||||
<attribute name="gradle_used_by_scope" value="test"/>
|
||||
<attribute name="test" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<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.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
|
||||
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
|
||||
<classpathentry kind="output" path="bin/default"/>
|
||||
</classpath>
|
||||
|
||||
@@ -35,40 +35,80 @@
|
||||
package zeroecho.pki.api.audit;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import zeroecho.pki.api.FormatId;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
|
||||
/**
|
||||
* Auditable event emitted by the PKI core.
|
||||
* Auditable event emitted by the PKI core and governance layer.
|
||||
*
|
||||
* <p>
|
||||
* Audit events may represent high-level PKI operations (issuance, revocation,
|
||||
* publication, backup) and attribute access governance outcomes.
|
||||
* Implementations must ensure no secrets appear in {@code details}.
|
||||
* An {@code AuditEvent} is an immutable, structured record describing a
|
||||
* security-relevant action or outcome. Typical event categories include
|
||||
* high-level PKI operations (issuance, revocation, publication, backup) and
|
||||
* attribute-level governance decisions (read/export attempts and outcomes).
|
||||
* </p>
|
||||
*
|
||||
* @param time event time (server time)
|
||||
* @param category non-empty category (e.g., "ISSUANCE", "REVOCATION",
|
||||
* "ATTRIBUTE_ACCESS")
|
||||
* @param action non-empty action string (e.g., "ISSUE_END_ENTITY", "REVOKE",
|
||||
* "READ")
|
||||
* @param principal actor responsible for the event
|
||||
* @param purpose purpose of the operation/access
|
||||
* @param objectId optional subject object id (credential id, request id, etc.)
|
||||
* @param formatId optional format id related to the object
|
||||
* @param details additional non-sensitive key/value details
|
||||
* <h2>Security properties</h2>
|
||||
* <ul>
|
||||
* <li><strong>No secrets:</strong> {@link #details()} MUST NOT contain secrets
|
||||
* (keys, seeds, shared secrets, plaintext, private material, or other sensitive
|
||||
* cryptographic/internal state). It is intended for non-sensitive metadata only
|
||||
* (e.g., decision, policy id, reason codes, counters).</li>
|
||||
* <li><strong>Minimality:</strong> callers should prefer coarse,
|
||||
* non-identifying fields and avoid excessive detail. If an identifier is
|
||||
* needed, prefer {@link #objectId()} and a stable, non-secret reference.</li>
|
||||
* </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,
|
||||
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
|
||||
* containers/maps are null
|
||||
* <p>
|
||||
* 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 {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
this.historySeq = new AtomicLong(0L);
|
||||
|
||||
LOG.log(Level.INFO, "running in {0}", root);
|
||||
|
||||
} catch (IOException 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
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.spi;
|
||||
package zeroecho.pki.spi.audit;
|
||||
|
||||
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.
|
||||
*
|
||||
* <p>
|
||||
* SPIs are used to plug in persistence, audit sinks, publishing targets, and
|
||||
* credential framework implementations. Public API types remain
|
||||
* framework-agnostic.
|
||||
* SPIs are used to plug in persistence backends, audit sinks, publishing
|
||||
* targets, and framework integrations while keeping the public PKI API
|
||||
* 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>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>
|
||||
* Implementations persist CA entities, credentials, requests, revocations,
|
||||
* status objects, profiles, policy traces, publication records, and optionally
|
||||
* audit events. Storage must be deterministic and suitable for backup/restore.
|
||||
* This package defines the authoritative persistence contract for PKI-managed
|
||||
* state (CAs, credentials, requests, revocations, status objects, profiles,
|
||||
* 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>
|
||||
*/
|
||||
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