Skip to content
Open
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
@@ -0,0 +1,175 @@
package com.epam.aidial.core.server.layout;

import com.epam.aidial.core.server.util.ProxyUtil;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.core.type.TypeReference;
import io.vertx.core.json.JsonObject;
import lombok.SneakyThrows;

import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;

/**
* Asks "what may this subject do to this resource?" of a running instance, once per cell, and records the
* answer so the two layouts can be compared on decisions rather than on responses.
*
* <p>The question goes over HTTP rather than into {@code AccessService} directly. {@code populatePermissions}
* runs the same eleven-rule chain and puts its result in the response, so this observes the real chain with a
* real context — where a hand-built context would mostly prove that the mock was set up the way the test
* expected.
*/
public class AccessMatrix {

private static final String MATRIX = "layout-diff/access-matrix.json";

/**
* @param subject who is asking — a key in the corpus's subject table
* @param resource logical url, with {@code ${…}} placeholders resolved from the seed run
* @param rule the permission rule this cell exists to exercise, for the coverage report
* @param expects the permissions this cell must yield. A cell that grants less than this is not
* exercising its rule, and a matrix that quietly stopped exercising the chain would
* compare "denied" to "denied" and call the layouts identical.
* @param forbids permissions this cell must not yield, for the boundaries worth pinning — a read-only
* share that starts granting write is not something to notice only in P3.
*/
@JsonIgnoreProperties(ignoreUnknown = false)
public record Cell(String name, String subject, String resource, String rule,
List<String> expects, List<String> forbids) {

public List<String> forbidsOrEmpty() {
return forbids == null ? List.of() : forbids;
}
}

/**
* What a subject turns into on the wire. {@code perRequest} builds an application caller — a per-request
* key issued against {@code apiKey}, optionally carrying attached deployments or its own source app.
*/
@JsonIgnoreProperties(ignoreUnknown = false)
public record Subject(String name, String apiKey, String authorization, PerRequest perRequest) {

@JsonIgnoreProperties(ignoreUnknown = false)
public record PerRequest(String sourceDeployment, Map<String, List<String>> attach,
Map<String, List<String>> share) {
}
}

/**
* @param chain every rule in {@code AccessService}'s permission chain, so a rule that no cell reaches
* is a visible gap rather than an absence nobody notices
* @param uncovered rules deliberately not covered, and why
*/
@JsonIgnoreProperties(ignoreUnknown = false)
public record Definition(List<String> chain, Map<String, String> uncovered,
List<Subject> subjects, List<Cell> cells) {
}

/**
* One cell's answer: the permissions granted, or why none were.
*/
public record Decision(String cell, String rule, int status, Set<String> permissions) {

public String describe() {
return status == 200 ? String.join(",", permissions) : "http " + status;
}
}

@SneakyThrows
public static Definition load() {
try (InputStream in = AccessMatrix.class.getClassLoader().getResourceAsStream(MATRIX)) {
if (in == null) {
throw new IllegalStateException(MATRIX + " is missing");
}
return ProxyUtil.MAPPER.readValue(in, new TypeReference<Definition>() { });
}
}

/**
* Evaluates every cell against one instance. Placeholders resolve from {@code variables}, which the seed
* run filled in — buckets, and anything the seed scenarios captured.
*/
public static Map<String, Decision> evaluate(DialInstance instance, Definition definition,
Map<String, String> variables) {
Map<String, Map<String, String>> credentials = resolveSubjects(instance, definition.subjects(), variables);

Map<String, Decision> decisions = new LinkedHashMap<>();
for (Cell cell : definition.cells()) {
Map<String, String> headers = credentials.get(cell.subject());
if (headers == null) {
throw new IllegalStateException("Cell '" + cell.name() + "' names unknown subject " + cell.subject());
}

String url = resolve(cell.resource(), variables);
RecordedResponse response =
instance.send("GET", "/v1/metadata/" + url, "permissions=true", null, headers, null);

decisions.put(cell.name(), new Decision(cell.name(), cell.rule(), response.status(),
permissions(response)));
}
return decisions;
}

private static Set<String> permissions(RecordedResponse response) {
if (response.status() != 200 || response.body() == null) {
return Set.of();
}

Set<String> granted = new TreeSet<>();
var array = new JsonObject(response.body()).getJsonArray("permissions");
if (array != null) {
array.forEach(permission -> granted.add(String.valueOf(permission)));
}
return granted;
}

/**
* Turns each subject into the credential its requests carry. Per-request keys are minted here rather than
* declared in the corpus because only a live instance can issue one.
*/
private static Map<String, Map<String, String>> resolveSubjects(DialInstance instance, List<Subject> subjects,
Map<String, String> variables) {
Map<String, Map<String, String>> credentials = new LinkedHashMap<>();
for (Subject subject : subjects) {
if (subject.authorization() != null) {
credentials.put(subject.name(), Map.of("authorization", subject.authorization()));
} else if (subject.perRequest() == null) {
credentials.put(subject.name(), Map.of("api-key", subject.apiKey()));
} else {
credentials.put(subject.name(), Map.of("api-key", instance.issuePerRequestKey(resolve(subject, variables))));
}
}
return credentials;
}

private static Subject resolve(Subject subject, Map<String, String> variables) {
Subject.PerRequest perRequest = subject.perRequest();
return new Subject(subject.name(), subject.apiKey(), subject.authorization(),
new Subject.PerRequest(resolve(perRequest.sourceDeployment(), variables),
resolve(perRequest.attach(), variables), resolve(perRequest.share(), variables)));
}

private static Map<String, List<String>> resolve(Map<String, List<String>> urls, Map<String, String> variables) {
if (urls == null) {
return null;
}
Map<String, List<String>> resolved = new LinkedHashMap<>();
urls.forEach((url, permissions) -> resolved.put(resolve(url, variables), permissions));
return resolved;
}

private static String resolve(String template, Map<String, String> variables) {
String resolved = template;
for (Map.Entry<String, String> variable : variables.entrySet()) {
resolved = resolved.replace("${" + variable.getKey() + "}", variable.getValue());
}
int unresolved = resolved.indexOf("${");
if (unresolved >= 0) {
throw new IllegalStateException("Unresolved placeholder in: " + resolved.substring(unresolved));
}
return resolved;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
public class CorpusRunner {

public static final String REPLAY_CORPUS = "layout-diff/corpus";
public static final String ACCESS_CORPUS = "layout-diff/access-corpus";

private static final String API_KEY_1 = "proxyKey1";
private static final String API_KEY_2 = "proxyKey2";
Expand Down Expand Up @@ -69,9 +70,12 @@ public static List<Scenario> loadCorpus(String dir) {
* runs must agree on it before any response comparison means anything — buckets thread through nearly every
* url in the corpus, so a difference there would make every other difference unreadable. {@code captures}
* holds what each scenario captured, by scenario name: normalisation replaces a captured value with its
* role name wherever it appears, so the values themselves are only comparable here.
* role name wherever it appears, so the values themselves are only comparable here. {@code variables} is
* the flat view of the same — buckets plus every capture — which the access matrix resolves its
* templates with.
*/
public record Run(Map<String, String> buckets,
Map<String, String> variables,
Map<String, Map<String, String>> captures,
Map<StepKey, RecordedResponse> responses) {
}
Expand All @@ -88,6 +92,7 @@ public static Run replay(DialInstance instance, List<Scenario> scenarios) {

Map<StepKey, RecordedResponse> recorded = new LinkedHashMap<>();
Map<String, Map<String, String>> captures = new LinkedHashMap<>();
Map<String, String> allVariables = new LinkedHashMap<>(buckets);
for (Scenario scenario : scenarios) {
Map<String, String> variables = new HashMap<>(buckets);
Map<StepKey, RecordedResponse> raw = new LinkedHashMap<>();
Expand Down Expand Up @@ -116,8 +121,9 @@ public static Run replay(DialInstance instance, List<Scenario> scenarios) {
Map<String, String> captured = new LinkedHashMap<>(variables);
captured.keySet().removeAll(buckets.keySet());
captures.put(scenario.name(), captured);
allVariables.putAll(variables);
}
return new Run(buckets, captures, recorded);
return new Run(buckets, allVariables, captures, recorded);
}

private static void capture(Scenario.Step step, RecordedResponse response, Map<String, String> variables) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
package com.epam.aidial.core.server.layout;

import com.epam.aidial.core.config.ResourceAccessType;
import com.epam.aidial.core.server.AiDial;
import com.epam.aidial.core.server.AiDialLifecycle;
import com.epam.aidial.core.server.FileUtil;
import com.epam.aidial.core.server.data.ApiKeyData;
import com.epam.aidial.core.server.data.AutoSharedData;
import com.epam.aidial.core.server.data.permission.PerRequestSharedData;
import com.epam.aidial.core.server.security.AccessTokenValidator;
import com.epam.aidial.core.server.security.ExtractedClaims;
import com.epam.aidial.core.server.util.ProxyUtil;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.vertx.core.Future;
import io.vertx.core.json.Json;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import lombok.Getter;
import lombok.SneakyThrows;
Expand All @@ -31,7 +36,9 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;

/**
* One full DIAL stack — embedded Redis, filesystem blob store, HTTP server — brought up under a chosen
Expand All @@ -44,6 +51,12 @@
*/
public class DialInstance implements AutoCloseable {

/**
* Role the global-reader rule matches. The rule is off unless {@code access.globalReader} is configured,
* so the harness configures it — otherwise the differ would compare a rule that can never fire.
*/
public static final String GLOBAL_READER_ROLE = "layout-diff-global-reader";

private static final String ID_SEED = "0";
private static final long FIRST_ID = 123;

Expand Down Expand Up @@ -127,7 +140,12 @@ private AiDial start(JsonObject layoutSettings, int redisPort) throws Exception

JsonObject settings = AiDial.settings()
.mergeIn(new JsonObject(overrides), true)
.mergeIn(new JsonObject().put("storage", new JsonObject().put("layout", layoutSettings)), true);
.mergeIn(new JsonObject().put("storage", new JsonObject().put("layout", layoutSettings)), true)
.mergeIn(new JsonObject().put("access", new JsonObject().put("globalReader", new JsonObject()
.put("rules", new JsonArray().add(new JsonObject()
.put("source", "roles")
.put("function", "EQUAL")
.put("targets", new JsonArray().add(GLOBAL_READER_ROLE)))))), true);

AiDial instance = new AiDial();
instance.setSettings(settings);
Expand All @@ -138,6 +156,10 @@ private AiDial start(JsonObject layoutSettings, int redisPort) throws Exception
return instance;
}

private static Set<ResourceAccessType> accessTypes(List<String> permissions) {
return permissions.stream().map(ResourceAccessType::valueOf).collect(Collectors.toSet());
}

private static AccessTokenValidator claimsValidator() {
AccessTokenValidator validator = Mockito.mock(AccessTokenValidator.class);
Mockito.when(validator.extractClaims(Mockito.any())).thenAnswer(invocation -> {
Expand All @@ -154,6 +176,38 @@ private static AccessTokenValidator claimsValidator() {
return validator;
}

/**
* Issues the api key an application-shaped caller uses. Four of the eleven permission rules only fire for
* a caller that is an application rather than a person, and a per-request key is what makes one; it can
* only be minted against a live instance, so the corpus declares the shape and this fills it in.
*/
public String issuePerRequestKey(AccessMatrix.Subject subject) {
ApiKeyData original = dial.getProxy().getApiKeyStore().getApiKeyData(subject.apiKey(), null).result();

ApiKeyData perRequest = new ApiKeyData();
perRequest.setOriginalKey(original.getOriginalKey());
perRequest.setExtractedClaims(original.getExtractedClaims());
perRequest.setSourceDeployment(subject.perRequest().sourceDeployment());
perRequest.setTraceId("layout-diff-trace");

Map<String, List<String>> attach = subject.perRequest().attach();
if (attach != null) {
Map<String, AutoSharedData> attached = new LinkedHashMap<>();
attach.forEach((url, permissions) -> attached.put(url, new AutoSharedData(accessTypes(permissions))));
perRequest.setAttachedDeployments(attached);
}

Map<String, List<String>> share = subject.perRequest().share();
if (share != null) {
Map<String, PerRequestSharedData> shared = new LinkedHashMap<>();
share.forEach((url, permissions) -> shared.put(url, new PerRequestSharedData(accessTypes(permissions))));
perRequest.setPerRequestSharedResources(shared);
}

dial.getProxy().getApiKeyStore().assignPerRequestApiKey(perRequest);
return perRequest.getPerRequestKey();
}

/**
* The bucket the given api key writes into. Derived from the caller identity and the encryption secret,
* neither of which the layout touches, so the two instances are expected to report the same value —
Expand Down
Loading