Skip to content

Commit be25152

Browse files
authored
Merge pull request #186 from teaql/test/masking-contract
Align default rendered SQL masking contract
2 parents 43c135a + aae448e commit be25152

66 files changed

Lines changed: 3246 additions & 293 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitattributes‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
test-vectors/*.tsv whitespace=-blank-at-eol

‎examples/security-foundations/pom.xml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
<artifactId>teaql-security-foundations-example</artifactId>
1313
<name>TeaQL security foundations focused example</name>
1414
<dependencies>
15+
<dependency>
16+
<groupId>io.teaql</groupId>
17+
<artifactId>teaql-sqlite</artifactId>
18+
<version>${project.version}</version>
19+
</dependency>
1520
<dependency>
1621
<groupId>io.teaql</groupId>
1722
<artifactId>teaql-runtime</artifactId>

‎examples/security-foundations/src/test/java/io/teaql/examples/security/SecurityFoundationsExampleTest.java‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
import org.junit.Test;
2121

2222
public class SecurityFoundationsExampleTest {
23+
@Test
24+
public void sqliteMaskingLifecycle() throws Exception {
25+
SqlMaskingLifecycleExample.verify();
26+
}
2327
private static final class OrderItem extends BaseEntity {
2428
OrderItem(long id, long version) {
2529
__internalSet("id", id);
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package io.teaql.examples.security;
2+
3+
import io.teaql.core.*;
4+
import io.teaql.core.criteria.Operator;
5+
import io.teaql.core.meta.SimpleEntityMetaFactory;
6+
import io.teaql.core.sql.SQLEntityDescriptor;
7+
import io.teaql.core.sqlite.SqliteDataServiceExecutor;
8+
import io.teaql.provider.jdbc.JdbcSqlExecutor;
9+
import io.teaql.runtime.*;
10+
import java.nio.file.Files;
11+
import java.util.*;
12+
import java.util.concurrent.atomic.AtomicLong;
13+
import org.sqlite.SQLiteDataSource;
14+
import static org.junit.Assert.*;
15+
16+
/** Runtime-owned fixture, not a hand-edited generated library. */
17+
final class SqlMaskingLifecycleExample {
18+
static void verify() throws Exception {
19+
var metadata = new SimpleEntityMetaFactory();
20+
var descriptor = new SQLEntityDescriptor();
21+
descriptor.setType("MaskFixture"); descriptor.setTargetType(MaskFixture.class);
22+
descriptor.setEntitySupplier(MaskFixture::new); descriptor.setDataService("sqlite");
23+
descriptor.addSimpleProperty("id", Long.class);
24+
descriptor.addSimpleProperty("version", Long.class);
25+
descriptor.addSimpleProperty("name", String.class);
26+
descriptor.setAuditMaskFields(List.of("name"));
27+
descriptor.with("table_name", "mask_fixture_data"); metadata.register(descriptor);
28+
var ds = new SQLiteDataSource();
29+
ds.setUrl("jdbc:sqlite:" + Files.createTempFile("teaql-java-mask-example-", ".db"));
30+
var provider = new SqliteDataServiceExecutor("sqlite", new JdbcSqlExecutor(ds), ds);
31+
var logs = new ArrayList<ExecutionMetadata>();
32+
var sequence = new AtomicLong(10);
33+
var context = new DefaultUserContext(TeaQLRuntime.builder().metadata(metadata)
34+
.dataService("sqlite", provider).idGenerationService((caller, entity) -> sequence.getAndIncrement())
35+
.logSink((caller, entry) -> logs.add(entry)).build());
36+
37+
assertThrows(RuntimeException.class, () -> query().executeForStream(context));
38+
assertEquals(1, logs.size());
39+
assertEquals("failure", logs.get(0).getExecutionOutcome());
40+
assertNull(logs.get(0).getResultCount());
41+
context.ensureSchema();
42+
for (int i = 0; i < 3; i++) {
43+
logs.clear();
44+
new MaskFixture().updateName("Riverside").auditAs("seed masking example Riverside").save(context);
45+
var readback = logs.stream().filter(e -> e.getOperation() == DataServiceOperation.QUERY
46+
&& String.valueOf(e.getParameterizedQuery()).contains("SELECT *")).findFirst().orElseThrow();
47+
assertEquals("seed masking example [REDACTED]", readback.getAuditReason());
48+
assertFalse(readback.getDebugQuery().contains("Riverside"));
49+
assertEquals(Integer.valueOf(1), readback.getResultCount());
50+
}
51+
MaskFixture changed;
52+
try (var rows = query().executeForStream(context)) { changed = rows.findFirst().orElseThrow(); }
53+
for (String name : List.of("Lakeside", "Riverside")) {
54+
logs.clear();
55+
changed.updateName(name).auditAs("update masked fixture " + name).save(context);
56+
var readback = logs.stream().filter(e -> e.getOperation() == DataServiceOperation.QUERY
57+
&& String.valueOf(e.getParameterizedQuery()).contains("SELECT *")).findFirst().orElseThrow();
58+
assertEquals("update masked fixture [REDACTED]", readback.getAuditReason());
59+
assertNull(readback.getIntentRedactions());
60+
}
61+
// Test-only fault injection, not application persistence SQL.
62+
try (var conn = ds.getConnection(); var statement = conn.createStatement()) {
63+
statement.execute("CREATE TRIGGER disappear_mask_fixture AFTER INSERT ON mask_fixture_data WHEN NEW.id >= 13 BEGIN DELETE FROM mask_fixture_data WHERE id = NEW.id; END");
64+
}
65+
try {
66+
logs.clear();
67+
assertThrows(RuntimeException.class, () -> new MaskFixture().updateName("READBACK-CANARY")
68+
.auditAs("verify missing READBACK-CANARY snapshot").save(context));
69+
var readback = logs.stream().filter(e -> e.getOperation() == DataServiceOperation.QUERY
70+
&& String.valueOf(e.getParameterizedQuery()).contains("SELECT *")).findFirst().orElseThrow();
71+
assertEquals("success", readback.getExecutionOutcome());
72+
assertEquals(Integer.valueOf(0), readback.getResultCount());
73+
assertEquals("verify missing [REDACTED] snapshot", readback.getAuditReason());
74+
assertTrue(logs.stream().anyMatch(e -> e.getOperation() == DataServiceOperation.MUTATION
75+
&& "success".equals(e.getExecutionOutcome()) && Long.valueOf(1).equals(e.getAffectedRows())));
76+
} finally {
77+
try (var conn = ds.getConnection(); var statement = conn.createStatement()) {
78+
statement.execute("DROP TRIGGER disappear_mask_fixture");
79+
}
80+
}
81+
System.out.println("PASS Java readback inherits sensitive intent for create/update and empty SQLite snapshot");
82+
logs.clear();
83+
var duplicateContext = new DefaultUserContext(TeaQLRuntime.builder().metadata(metadata)
84+
.dataService("sqlite", provider).idGenerationService((caller, entity) -> 10L)
85+
.logSink((caller, entry) -> logs.add(entry)).build());
86+
assertThrows(RuntimeException.class, () -> new MaskFixture().updateName("MASKED-DUPLICATE-CANARY")
87+
.auditAs("verify failed duplicate insert").save(duplicateContext));
88+
assertTrue(logs.stream().anyMatch(entry -> entry.getOperation() == DataServiceOperation.MUTATION));
89+
assertTrue(logs.stream().noneMatch(entry -> String.valueOf(entry.getDebugQuery()).contains("MASKED-DUPLICATE-CANARY")));
90+
logs.clear();
91+
try (var rows = query().executeForStream(context)) {
92+
assertEquals(1, rows.limit(1).count());
93+
}
94+
assertEquals(1, logs.size());
95+
assertEquals("cancelled", logs.get(0).getExecutionOutcome());
96+
assertEquals(Integer.valueOf(1), logs.get(0).getResultCount());
97+
assertEquals("verify cursor diagnostics", logs.get(0).getPurpose());
98+
assertTrue(logs.get(0).getDebugQuery().contains("Ri*****de"));
99+
assertFalse(logs.get(0).getDebugQuery().contains("Riverside"));
100+
logs.clear();
101+
try (var rows = query().executeForStream(context)) {
102+
assertEquals(List.of("Riverside", "Riverside", "Riverside"), rows.map(MaskFixture::getName).toList());
103+
}
104+
assertEquals(1, logs.size());
105+
assertEquals("success", logs.get(0).getExecutionOutcome());
106+
assertEquals(Integer.valueOf(3), logs.get(0).getResultCount());
107+
// Re-emitting a stored safe record must never upgrade it to debug plaintext.
108+
var retained = logs.get(0);
109+
var replay = LogPrivacy.sql(retained, true);
110+
assertEquals(retained.getDebugQuery(), replay.getDebugQuery());
111+
var output = Files.createTempFile("teaql-java-masked-replay-", ".log");
112+
try (var out = new java.io.PrintStream(Files.newOutputStream(output))) {
113+
new DefaultTextRuntimeLogSink(out).writeExecutionLog(context, replay);
114+
}
115+
String text = Files.readString(output);
116+
assertTrue(text.contains("Ri*****de"));
117+
assertFalse(text.contains("Riverside"));
118+
assertFalse(text.contains("DEBUG PLAINTEXT"));
119+
assertTrue(text.contains("verify cursor diagnostics"));
120+
System.out.println("PASS Java retained SQL projection: no plaintext upgrade, masked file output, intent preserved");
121+
System.out.println("PASS Java SQLite masking lifecycle: failed open/duplicate insert, early close, completion, intent and original data");
122+
}
123+
124+
private static ExecutableRequest<MaskFixture> query() {
125+
var request = new FixtureRequest();
126+
request.setSize(3);
127+
request.appendSearchCriteria(request.createBasicSearchCriteria("name", Operator.EQUAL, "Riverside"));
128+
return request.comment("read masked fixture").purpose("verify cursor diagnostics");
129+
}
130+
131+
public static final class FixtureRequest extends BaseRequest<MaskFixture> {
132+
FixtureRequest() { super(MaskFixture.class); }
133+
@Override public String getTypeName() { return "MaskFixture"; }
134+
public FixtureRequest comment(String text) { internalComment(text); return this; }
135+
}
136+
137+
public static final class MaskFixture extends BaseEntity {
138+
private String name;
139+
@Override public String typeName() { return "MaskFixture"; }
140+
public String getName() { return name; }
141+
public MaskFixture updateName(String value) { handleUpdate("name", name, value); name = value; return this; }
142+
@Override public void __internalSet(String property, Object value) {
143+
if (property.equals("name")) name = (String) value; else super.__internalSet(property, value);
144+
}
145+
@Override public Object __internalGet(String property) {
146+
return property.equals("name") ? name : super.__internalGet(property);
147+
}
148+
}
149+
}

0 commit comments

Comments
 (0)