Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
Expand All @@ -10,6 +12,7 @@
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
Expand Down Expand Up @@ -37,6 +40,7 @@ public class ArtifactLinkLedger {
private final ConcurrentMap<String, ArtifactLinkRecord> issuedLinks = new ConcurrentHashMap<>();
private final ConcurrentLinkedQueue<ArtifactReadEvent> readEvents = new ConcurrentLinkedQueue<>();
private final Path ledgerPath;
private final DurableLineAppender durableLineAppender;

/**
* Creates an in-memory artifact link ledger.
Expand All @@ -56,18 +60,32 @@ public ArtifactLinkLedger(@Value("${clearfolio.artifact-link-ledger.path:}") Str
}

ArtifactLinkLedger(Path ledgerPath) {
this(ledgerPath, ArtifactLinkLedger::appendDurably);
}

ArtifactLinkLedger(Path ledgerPath, DurableLineAppender durableLineAppender) {
this.ledgerPath = ledgerPath;
this.durableLineAppender = Objects.requireNonNull(durableLineAppender, "durableLineAppender");
load();
}

/**
* Records an issued artifact link.
*
* <p>Token identifiers are immutable authorization identities. Reusing an
* already-issued identifier is rejected before durable append or process-local
* publication so a collision or caller error cannot rebind an existing token
* to another tenant, subject, or document.</p>
*
* @param record issued artifact link record
* @throws IllegalStateException when the token identifier was already issued or durable append fails
*/
public synchronized void recordIssued(ArtifactLinkRecord record) {
issuedLinks.put(record.tokenId(), record);
if (issuedLinks.containsKey(record.tokenId())) {
throw new IllegalStateException("artifact token identifier is already issued");
}
appendLine(serializeIssued(record));
issuedLinks.put(record.tokenId(), record);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
Expand Down Expand Up @@ -97,18 +115,14 @@ public synchronized Optional<ArtifactLinkRecord> revoke(
Instant revokedAt,
String revokedBy,
String reason) {
boolean[] changed = {false};
ArtifactLinkRecord revoked = issuedLinks.computeIfPresent(tokenId, (ignored, current) -> {
if (current.isRevoked()) {
return current;
}
changed[0] = true;
return current.revoked(revokedAt, revokedBy, reason);
});
if (changed[0]) {
appendLine(serializeRevoked(revoked));
ArtifactLinkRecord current = issuedLinks.get(tokenId);
if (current == null || current.isRevoked()) {
return Optional.ofNullable(current);
}
return Optional.ofNullable(revoked);
ArtifactLinkRecord revoked = current.revoked(revokedAt, revokedBy, reason);
appendLine(serializeRevoked(revoked));
issuedLinks.put(tokenId, revoked);
return Optional.of(revoked);
}

/**
Expand All @@ -117,8 +131,8 @@ public synchronized Optional<ArtifactLinkRecord> revoke(
* @param event artifact read event
*/
public synchronized void recordRead(ArtifactReadEvent event) {
readEvents.add(event);
appendLine(serializeRead(event));
readEvents.add(event);
}

/**
Expand Down Expand Up @@ -177,7 +191,9 @@ private void replayIssued(String[] fields) {
value(fields[12]),
value(fields[13])
);
issuedLinks.put(record.tokenId(), record);
if (issuedLinks.putIfAbsent(record.tokenId(), record) != null) {
throw invalidLine();
}
}

private void replayRevoked(String[] fields) {
Expand Down Expand Up @@ -217,20 +233,27 @@ private void appendLine(String line) {
return;
}
try {
Files.createDirectories(ledgerPath.toAbsolutePath().getParent());
Files.writeString(
ledgerPath,
line + System.lineSeparator(),
StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.APPEND
);
durableLineAppender.append(ledgerPath, line + System.lineSeparator());
} catch (IOException ex) {
throw new IllegalStateException("artifact link ledger cannot be written", ex);
}
}

private static void appendDurably(Path ledgerPath, String line) throws IOException {
Files.createDirectories(ledgerPath.toAbsolutePath().getParent());
try (FileChannel channel = FileChannel.open(
ledgerPath,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.APPEND)) {
ByteBuffer bytes = StandardCharsets.UTF_8.encode(line);
while (bytes.hasRemaining()) {
channel.write(bytes);
}
channel.force(true);
}
}

private static String serializeIssued(ArtifactLinkRecord record) {
return String.join("\t",
ISSUED,
Expand Down Expand Up @@ -342,4 +365,9 @@ private static IllegalStateException invalidLine() {
private static IllegalStateException invalidLine(Throwable cause) {
return new IllegalStateException("artifact link ledger contains an invalid line", cause);
}

@FunctionalInterface
interface DurableLineAppender {
void append(Path ledgerPath, String line) throws IOException;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package com.clearfolio.viewer.artifact;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.Base64;
import java.util.UUID;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

class ArtifactLinkLedgerAuthorityRebindingTest {

@TempDir
private Path tempDir;

@Test
void duplicateTokenIdentifierCannotRebindIssuedAuthority() {
Path ledgerPath = tempDir.resolve("artifact-ledger.log");
ArtifactLinkLedger ledger = new ArtifactLinkLedger(ledgerPath);
ArtifactLinkRecord original = issuedRecord("token-1", "tenant-a", UUID.randomUUID());
ArtifactLinkRecord conflicting = issuedRecord("token-1", "tenant-b", UUID.randomUUID());

ledger.recordIssued(original);

IllegalStateException error = assertThrows(
IllegalStateException.class,
() -> ledger.recordIssued(conflicting)
);

assertEquals("artifact token identifier is already issued", error.getMessage());
assertEquals(original, ledger.findByTokenId("token-1").orElseThrow());
assertEquals(original, new ArtifactLinkLedger(ledgerPath).findByTokenId("token-1").orElseThrow());
}

@Test
void replayRejectsPersistedTokenAuthorityRebinding() throws Exception {
Path ledgerPath = tempDir.resolve("artifact-ledger-replay.log");
ArtifactLinkRecord original = issuedRecord("token-1", "tenant-a", UUID.randomUUID());
ArtifactLinkRecord conflicting = issuedRecord("token-1", "tenant-b", UUID.randomUUID());
ArtifactLinkLedger ledger = new ArtifactLinkLedger(ledgerPath);
ledger.recordIssued(original);
Files.writeString(
ledgerPath,
issuedLine(conflicting) + System.lineSeparator(),
StandardCharsets.UTF_8,
StandardOpenOption.APPEND
);

IllegalStateException error = assertThrows(
IllegalStateException.class,
() -> new ArtifactLinkLedger(ledgerPath)
);

assertEquals("artifact link ledger contains an invalid line", error.getMessage());
}

private static ArtifactLinkRecord issuedRecord(String tokenId, String tenantId, UUID docId) {
return new ArtifactLinkRecord(
tokenId,
tenantId,
"subject-a",
docId,
ArtifactLinkService.ARTIFACT_READ_SCOPE,
"viewer-preview",
"checksum",
null,
Instant.EPOCH,
Instant.EPOCH.plusSeconds(300),
null,
null,
null
);
}

private static String issuedLine(ArtifactLinkRecord record) {
return String.join("\t",
"ISSUED",
encoded(record.tokenId()),
encoded(record.tenantId()),
encoded(record.subjectId()),
record.docId().toString(),
encoded(record.scope()),
encoded(record.purpose()),
encoded(record.artifactChecksum()),
"-",
record.issuedAt().toString(),
record.expiresAt().toString(),
"-",
"-",
"-"
);
}

private static String encoded(String value) {
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(value.getBytes(StandardCharsets.UTF_8));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.clearfolio.viewer.artifact;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.UUID;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

class ArtifactLinkLedgerDurabilityTest {

@Test
void doesNotPublishIssuedAuthorityWhenDurableAcknowledgementFails(@TempDir Path tempDir) {
Path ledgerPath = tempDir.resolve("artifact-links.ledger");
ArtifactLinkLedger ledger = new ArtifactLinkLedger(ledgerPath, (path, line) -> {
Files.writeString(
path,
line,
StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.APPEND
);
throw new IOException("durable acknowledgement failed");
});
ArtifactLinkRecord record = issuedRecord();

IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> ledger.recordIssued(record)
);

assertEquals("artifact link ledger cannot be written", failure.getMessage());
assertTrue(ledger.findByTokenId(record.tokenId()).isEmpty());
assertTrue(Files.exists(ledgerPath));
}

private static ArtifactLinkRecord issuedRecord() {
Instant issuedAt = Instant.parse("2026-08-11T04:00:00Z");
return new ArtifactLinkRecord(
"token-1",
"tenant-1",
"subject-1",
UUID.fromString("00000000-0000-0000-0000-000000000357"),
"artifact:read",
"viewer-preview",
"checksum",
null,
issuedAt,
issuedAt.plusSeconds(300),
null,
null,
null
);
}
}
Loading
Loading