diff --git a/server/src/test/java/com/epam/aidial/core/server/layout/AccessMatrix.java b/server/src/test/java/com/epam/aidial/core/server/layout/AccessMatrix.java new file mode 100644 index 000000000..a206f0c7d --- /dev/null +++ b/server/src/test/java/com/epam/aidial/core/server/layout/AccessMatrix.java @@ -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. + * + *

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 expects, List forbids) { + + public List 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> attach, + Map> 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 chain, Map uncovered, + List subjects, List cells) { + } + + /** + * One cell's answer: the permissions granted, or why none were. + */ + public record Decision(String cell, String rule, int status, Set 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() { }); + } + } + + /** + * 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 evaluate(DialInstance instance, Definition definition, + Map variables) { + Map> credentials = resolveSubjects(instance, definition.subjects(), variables); + + Map decisions = new LinkedHashMap<>(); + for (Cell cell : definition.cells()) { + Map 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 permissions(RecordedResponse response) { + if (response.status() != 200 || response.body() == null) { + return Set.of(); + } + + Set 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> resolveSubjects(DialInstance instance, List subjects, + Map variables) { + Map> 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 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> resolve(Map> urls, Map variables) { + if (urls == null) { + return null; + } + Map> resolved = new LinkedHashMap<>(); + urls.forEach((url, permissions) -> resolved.put(resolve(url, variables), permissions)); + return resolved; + } + + private static String resolve(String template, Map variables) { + String resolved = template; + for (Map.Entry 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; + } +} diff --git a/server/src/test/java/com/epam/aidial/core/server/layout/CorpusRunner.java b/server/src/test/java/com/epam/aidial/core/server/layout/CorpusRunner.java index f079717ef..7358f01db 100644 --- a/server/src/test/java/com/epam/aidial/core/server/layout/CorpusRunner.java +++ b/server/src/test/java/com/epam/aidial/core/server/layout/CorpusRunner.java @@ -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"; @@ -69,9 +70,12 @@ public static List 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 buckets, + Map variables, Map> captures, Map responses) { } @@ -88,6 +92,7 @@ public static Run replay(DialInstance instance, List scenarios) { Map recorded = new LinkedHashMap<>(); Map> captures = new LinkedHashMap<>(); + Map allVariables = new LinkedHashMap<>(buckets); for (Scenario scenario : scenarios) { Map variables = new HashMap<>(buckets); Map raw = new LinkedHashMap<>(); @@ -116,8 +121,9 @@ public static Run replay(DialInstance instance, List scenarios) { Map 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 variables) { diff --git a/server/src/test/java/com/epam/aidial/core/server/layout/DialInstance.java b/server/src/test/java/com/epam/aidial/core/server/layout/DialInstance.java index dc7ab5d2a..ea88855bb 100644 --- a/server/src/test/java/com/epam/aidial/core/server/layout/DialInstance.java +++ b/server/src/test/java/com/epam/aidial/core/server/layout/DialInstance.java @@ -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; @@ -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 @@ -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; @@ -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); @@ -138,6 +156,10 @@ private AiDial start(JsonObject layoutSettings, int redisPort) throws Exception return instance; } + private static Set accessTypes(List 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 -> { @@ -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> attach = subject.perRequest().attach(); + if (attach != null) { + Map attached = new LinkedHashMap<>(); + attach.forEach((url, permissions) -> attached.put(url, new AutoSharedData(accessTypes(permissions)))); + perRequest.setAttachedDeployments(attached); + } + + Map> share = subject.perRequest().share(); + if (share != null) { + Map 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 — diff --git a/server/src/test/java/com/epam/aidial/core/server/layout/LayoutAccessDifferTest.java b/server/src/test/java/com/epam/aidial/core/server/layout/LayoutAccessDifferTest.java new file mode 100644 index 000000000..e85bcdf9f --- /dev/null +++ b/server/src/test/java/com/epam/aidial/core/server/layout/LayoutAccessDifferTest.java @@ -0,0 +1,164 @@ +package com.epam.aidial.core.server.layout; + +import com.epam.aidial.core.server.util.ProxyUtil; +import com.epam.aidial.core.storage.resource.LegacyStorageLayout; +import com.epam.aidial.core.storage.resource.StorageLayouts; +import io.vertx.core.json.JsonObject; +import lombok.SneakyThrows; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Asks the same access questions of both layouts and fails on any answer that differs. + * + *

It should come back clean: shares and rules key on logical urls and {@code bucketLocation} stays legacy, + * so the inputs to the permission chain never change. That is an assumption spanning eleven rules — cheap to + * verify, expensive to be wrong about — and the same differ is what answers "does the new engine decide + * identically?" in P3, when there is a genuine rewrite to check and no reference implementation left to + * capture behaviour from. + */ +@Tag("layout-diff") +public class LayoutAccessDifferTest { + + private static final String TENANT = "layout-diff-tenant"; + + private static final int LEGACY_REDIS_PORT = 16373; + private static final int TENANT_ROOTED_REDIS_PORT = 16374; + + private static final Path REPORT_DIR = Paths.get("build", "reports", "layout-diff"); + + private record Run(Map variables, Map decisions) { + } + + @AfterEach + public void restoreLegacyLayout() { + StorageLayouts.useLayout(LegacyStorageLayout.INSTANCE); + } + + @Test + public void testBothLayoutsDecideIdentically() { + List seed = CorpusRunner.loadCorpus(CorpusRunner.ACCESS_CORPUS); + AccessMatrix.Definition matrix = AccessMatrix.load(); + + Run legacy = run("access-legacy", new JsonObject().put("tenantRooted", false), + LEGACY_REDIS_PORT, seed, matrix); + Run tenantRooted = run("access-tenant-rooted", new JsonObject() + .put("tenantRooted", true) + .put("defaultTenant", TENANT), + TENANT_ROOTED_REDIS_PORT, seed, matrix); + + write("access-legacy.json", legacy.decisions()); + write("access-tenant-rooted.json", tenantRooted.decisions()); + + List problems = new ArrayList<>(); + problems.addAll(vacuousCells(matrix, legacy.decisions())); + problems.addAll(uncoveredRules(matrix, legacy.decisions())); + problems.addAll(divergences(legacy.decisions(), tenantRooted.decisions())); + + if (!problems.isEmpty()) { + fail(String.join("\n", problems)); + } + } + + /** + * A cell that grants less than it declares is not exercising its rule. Without this the suite would + * happily compare "denied" to "denied" across every cell and report the layouts identical. The dual + * holds for denial cells: empty {@code expects} asserts that nothing is granted, not that nothing is + * expected — otherwise both layouts granting the stranger READ would compare equal and pass, and + * lookupPermissions is a union over the whole chain, so denial means no rule granted anywhere. + */ + private static List vacuousCells(AccessMatrix.Definition matrix, + Map decisions) { + List problems = new ArrayList<>(); + for (AccessMatrix.Cell cell : matrix.cells()) { + AccessMatrix.Decision decision = decisions.get(cell.name()); + if (cell.expects().isEmpty() && !decision.permissions().isEmpty()) { + problems.add("Cell '" + cell.name() + "' (" + cell.rule() + ") expects denial but got " + + decision.describe() + "."); + } + if (!decision.permissions().containsAll(cell.expects())) { + problems.add("Cell '" + cell.name() + "' (" + cell.rule() + ") expected at least " + + cell.expects() + " but got " + decision.describe() + + " — the matrix is not exercising the rule it claims to."); + } + for (String forbidden : cell.forbidsOrEmpty()) { + if (decision.permissions().contains(forbidden)) { + problems.add("Cell '" + cell.name() + "' (" + cell.rule() + ") must not grant " + + forbidden + " but got " + decision.describe() + "."); + } + } + } + return problems; + } + + /** + * The chain is a union over eleven rules, and the ones that fire rarely are the ones a re-addressing bug + * would break silently. A rule with no cell that actually grants through it is not being compared at all. + */ + private static List uncoveredRules(AccessMatrix.Definition matrix, + Map decisions) { + Set granting = new LinkedHashSet<>(); + for (AccessMatrix.Cell cell : matrix.cells()) { + if (!decisions.get(cell.name()).permissions().isEmpty()) { + granting.add(cell.rule()); + } + } + + List problems = new ArrayList<>(); + for (String rule : matrix.chain()) { + if (granting.contains(rule)) { + continue; + } + String reason = matrix.uncovered().get(rule); + if (reason == null) { + problems.add("Rule '" + rule + "' in the permission chain is not covered by any cell that " + + "grants, and is not listed under \"uncovered\" with a reason."); + } + } + return problems; + } + + private static List divergences(Map legacy, + Map tenantRooted) { + List problems = new ArrayList<>(); + for (Map.Entry entry : legacy.entrySet()) { + AccessMatrix.Decision left = entry.getValue(); + AccessMatrix.Decision right = tenantRooted.get(entry.getKey()); + if (right == null || !left.permissions().equals(right.permissions()) || left.status() != right.status()) { + problems.add("Access decision differs for '" + entry.getKey() + "' (" + left.rule() + "):\n" + + " legacy: " + left.describe() + "\n" + + " tenantRooted: " + (right == null ? "missing" : right.describe())); + } + } + return problems; + } + + private static Run run(String name, JsonObject layoutSettings, int redisPort, + List seed, AccessMatrix.Definition matrix) { + try (DialInstance instance = new DialInstance(name, layoutSettings, redisPort)) { + CorpusRunner.Run seeded = CorpusRunner.replay(instance, seed); + return new Run(seeded.variables(), AccessMatrix.evaluate(instance, matrix, seeded.variables())); + } finally { + StorageLayouts.useLayout(LegacyStorageLayout.INSTANCE); + } + } + + @SneakyThrows + private static void write(String file, Object content) { + Files.createDirectories(REPORT_DIR); + Files.writeString(REPORT_DIR.resolve(file), + ProxyUtil.MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(content)); + } +} diff --git a/server/src/test/resources/layout-diff/access-corpus/01-seed.json b/server/src/test/resources/layout-diff/access-corpus/01-seed.json new file mode 100644 index 000000000..90d44577f --- /dev/null +++ b/server/src/test/resources/layout-diff/access-corpus/01-seed.json @@ -0,0 +1,273 @@ +{ + "name": "access-seed", + "description": "Leaves behind the state the access matrix asks about. Unlike the replay corpus these scenarios deliberately do not clean up: a revoked share or a deleted resource would make every cell answer 'denied', and a matrix of denials would compare equal under both layouts while proving nothing.", + "steps": [ + { + "name": "create-own", + "method": "PUT", + "path": "/v1/conversations/${bucket1}/access/own", + "body": { + "id": "own", + "name": "own", + "model": { + "id": "model_id" + }, + "prompt": "p", + "temperature": 1, + "folderId": "access", + "messages": [], + "assistantModelId": "a", + "lastActivityDate": 1 + } + }, + { + "name": "create-to-share", + "method": "PUT", + "path": "/v1/conversations/${bucket1}/access/shared", + "body": { + "id": "shared", + "name": "shared", + "model": { + "id": "model_id" + }, + "prompt": "p", + "temperature": 1, + "folderId": "access", + "messages": [], + "assistantModelId": "a", + "lastActivityDate": 2 + } + }, + { + "name": "share-read", + "method": "POST", + "path": "/v1/ops/resource/share/create", + "body": { + "invitationType": "link", + "resources": [ + { + "url": "conversations/${bucket1}/access/shared" + } + ] + }, + "capture": { + "readInvitationId": { + "at": "/invitationLink", + "extract": "/v1/invitations/(.+)$" + } + } + }, + { + "name": "accept-read-share", + "method": "GET", + "path": "/v1/invitations/${readInvitationId}", + "query": "accept=true", + "headers": { + "api-key": "proxyKey2" + } + }, + { + "name": "create-to-share-write", + "method": "PUT", + "path": "/v1/conversations/${bucket1}/access/writable", + "body": { + "id": "writable", + "name": "writable", + "model": { + "id": "model_id" + }, + "prompt": "p", + "temperature": 1, + "folderId": "access", + "messages": [], + "assistantModelId": "a", + "lastActivityDate": 3 + } + }, + { + "name": "share-write", + "method": "POST", + "path": "/v1/ops/resource/share/create", + "body": { + "invitationType": "link", + "resources": [ + { + "url": "conversations/${bucket1}/access/writable", + "permissions": [ + "READ", + "WRITE" + ] + } + ] + }, + "capture": { + "writeInvitationId": { + "at": "/invitationLink", + "extract": "/v1/invitations/(.+)$" + } + } + }, + { + "name": "accept-write-share", + "method": "GET", + "path": "/v1/invitations/${writeInvitationId}", + "query": "accept=true", + "headers": { + "api-key": "proxyKey2" + } + }, + { + "name": "create-application", + "method": "PUT", + "path": "/v1/applications/${bucket1}/access/app", + "body": { + "display_name": "app", + "display_version": "1.0", + "endpoint": "http://localhost:9999/completions", + "description": "access matrix" + } + }, + { + "name": "create-to-publish", + "method": "PUT", + "path": "/v1/conversations/${bucket1}/access/publishable", + "body": { + "id": "publishable", + "name": "publishable", + "model": { + "id": "model_id" + }, + "prompt": "p", + "temperature": 1, + "folderId": "access", + "messages": [], + "assistantModelId": "a", + "lastActivityDate": 4 + } + }, + { + "name": "publish", + "method": "POST", + "path": "/v1/ops/publication/create", + "body": { + "name": "access matrix publication", + "targetFolder": "public/access/", + "resources": [ + { + "action": "ADD", + "sourceUrl": "conversations/${bucket1}/access/publishable", + "targetUrl": "conversations/public/access/published" + } + ], + "rules": [ + { + "source": "roles", + "function": "TRUE" + } + ] + }, + "capture": { + "approvedPublicationUrl": { + "at": "/url" + } + } + }, + { + "name": "approve", + "method": "POST", + "path": "/v1/ops/publication/approve", + "body": { + "url": "${approvedPublicationUrl}" + }, + "headers": { + "authorization": "admin" + } + }, + { + "name": "create-to-review", + "method": "PUT", + "path": "/v1/conversations/${bucket1}/access/reviewable", + "body": { + "id": "reviewable", + "name": "reviewable", + "model": { + "id": "model_id" + }, + "prompt": "p", + "temperature": 1, + "folderId": "access", + "messages": [], + "assistantModelId": "a", + "lastActivityDate": 5 + } + }, + { + "name": "submit-for-review", + "method": "POST", + "path": "/v1/ops/publication/create", + "body": { + "name": "access matrix review", + "targetFolder": "public/review/", + "resources": [ + { + "action": "ADD", + "sourceUrl": "conversations/${bucket1}/access/reviewable", + "targetUrl": "conversations/public/review/reviewed" + } + ], + "rules": [ + { + "source": "roles", + "function": "TRUE" + } + ] + }, + "capture": { + "reviewedUrl": { + "at": "/resources/0/reviewUrl" + } + } + }, + { + "name": "create-appdata", + "method": "PUT", + "path": "/v1/files/${bucket1}/appdata/testapp/data.txt", + "body": "application scoped data", + "multipart": { + "filename": "data.txt", + "contentType": "text/plain" + } + }, + { + "name": "create-other-app-data", + "method": "PUT", + "path": "/v1/files/${bucket1}/appdata/otherapp/data.txt", + "body": "another application's data", + "multipart": { + "filename": "data.txt", + "contentType": "text/plain" + } + }, + { + "name": "second-key-creates-own", + "method": "PUT", + "path": "/v1/conversations/${bucket2}/access/second", + "headers": { + "api-key": "proxyKey2" + }, + "body": { + "id": "second", + "name": "second", + "model": { + "id": "model_id" + }, + "prompt": "p", + "temperature": 1, + "folderId": "access", + "messages": [], + "assistantModelId": "a", + "lastActivityDate": 6 + } + } + ] +} diff --git a/server/src/test/resources/layout-diff/access-matrix.json b/server/src/test/resources/layout-diff/access-matrix.json new file mode 100644 index 000000000..96898c2f6 --- /dev/null +++ b/server/src/test/resources/layout-diff/access-matrix.json @@ -0,0 +1,275 @@ +{ + "chain": [ + "getOwnResourcesAccess", + "getAdminAccess", + "getGlobalReaderAccess", + "getAutoSharedAccess", + "getPerRequestPermissions", + "getAppResourceAccess", + "getReviewAccess", + "getPublicAccess", + "getSharedAccess", + "getAppSelfAccess", + "getOwnResourcesAccessForChainedSchemaRichApplication" + ], + "uncovered": { + "getOwnResourcesAccessForChainedSchemaRichApplication": "Needs context.getDeployment() to be an Application carrying an app type schema id, and only the deployment-routing controllers ever set a deployment \u2014 ResourceController does not, so the rule returns nothing for every request this differ can make. Covering it means driving a request through DeploymentPostController against a deployed schema-rich application, which is a different fixture from anything here." + }, + "subjects": [ + { + "name": "owner", + "apiKey": "proxyKey1" + }, + { + "name": "other", + "apiKey": "proxyKey2" + }, + { + "name": "stranger", + "apiKey": "proxyKey3" + }, + { + "name": "admin", + "authorization": "admin" + }, + { + "name": "user", + "authorization": "user" + }, + { + "name": "app", + "apiKey": "proxyKey1", + "perRequest": { + "sourceDeployment": "applications/${bucket1}/access/app" + } + }, + { + "name": "appWithAttachment", + "apiKey": "proxyKey1", + "perRequest": { + "sourceDeployment": "testapp", + "attach": { + "conversations/${bucket1}/access/own": [ + "READ" + ] + } + } + }, + { + "name": "globalReader", + "authorization": "layout-diff-global-reader" + }, + { + "name": "appdataApp", + "apiKey": "proxyKey1", + "perRequest": { + "sourceDeployment": "testapp" + } + }, + { + "name": "appWithPerRequestShare", + "apiKey": "proxyKey1", + "perRequest": { + "sourceDeployment": "testapp", + "share": { + "conversations/${bucket2}/access/second": [ + "READ" + ] + } + } + } + ], + "cells": [ + { + "name": "owner-reads-own", + "subject": "owner", + "resource": "conversations/${bucket1}/access/own", + "rule": "getOwnResourcesAccess", + "expects": [ + "READ", + "WRITE" + ] + }, + { + "name": "stranger-denied-own", + "subject": "stranger", + "resource": "conversations/${bucket1}/access/own", + "rule": "getOwnResourcesAccess", + "expects": [] + }, + { + "name": "admin-writes-public", + "subject": "admin", + "resource": "conversations/public/access/published", + "rule": "getAdminAccess", + "expects": [ + "READ", + "WRITE" + ] + }, + { + "name": "admin-denied-private-resource", + "subject": "admin", + "resource": "conversations/${bucket1}/access/own", + "rule": "getAdminAccess", + "expects": [] + }, + { + "name": "plain-user-only-reads-public", + "subject": "user", + "resource": "conversations/public/access/published", + "rule": "getPublicAccess", + "expects": [ + "READ" + ] + }, + { + "name": "recipient-reads-shared", + "subject": "other", + "resource": "conversations/${bucket1}/access/shared", + "rule": "getSharedAccess", + "expects": [ + "READ" + ], + "forbids": [ + "WRITE" + ] + }, + { + "name": "stranger-denied-shared", + "subject": "stranger", + "resource": "conversations/${bucket1}/access/shared", + "rule": "getSharedAccess", + "expects": [] + }, + { + "name": "recipient-writes-write-shared", + "subject": "other", + "resource": "conversations/${bucket1}/access/writable", + "rule": "getSharedAccess", + "expects": [ + "READ", + "WRITE" + ] + }, + { + "name": "anyone-reads-published", + "subject": "other", + "resource": "conversations/public/access/published", + "rule": "getPublicAccess", + "expects": [ + "READ" + ], + "forbids": [ + "WRITE" + ] + }, + { + "name": "stranger-reads-published", + "subject": "stranger", + "resource": "conversations/public/access/published", + "rule": "getPublicAccess", + "expects": [ + "READ" + ] + }, + { + "name": "owner-reads-review-copy", + "subject": "owner", + "resource": "${reviewedUrl}", + "rule": "getReviewAccess", + "expects": [ + "READ" + ], + "forbids": [ + "WRITE" + ] + }, + { + "name": "stranger-denied-review-copy", + "subject": "stranger", + "resource": "${reviewedUrl}", + "rule": "getReviewAccess", + "expects": [] + }, + { + "name": "app-reads-attached-resource", + "subject": "appWithAttachment", + "resource": "conversations/${bucket1}/access/own", + "rule": "getAutoSharedAccess", + "expects": [ + "READ" + ] + }, + { + "name": "app-denied-unattached-resource", + "subject": "appWithAttachment", + "resource": "conversations/${bucket1}/access/shared", + "rule": "getAutoSharedAccess", + "expects": [] + }, + { + "name": "app-reads-own-configuration", + "subject": "app", + "resource": "applications/${bucket1}/access/app", + "rule": "getAppSelfAccess", + "expects": [ + "READ" + ] + }, + { + "name": "global-reader-reads-private", + "subject": "globalReader", + "resource": "conversations/${bucket1}/access/own", + "rule": "getGlobalReaderAccess", + "expects": [ + "READ" + ] + }, + { + "name": "global-reader-cannot-write", + "subject": "globalReader", + "resource": "conversations/${bucket1}/access/own", + "rule": "getGlobalReaderAccess", + "expects": [ + "READ" + ], + "forbids": [ + "WRITE" + ] + }, + { + "name": "app-reads-own-appdata", + "subject": "appdataApp", + "resource": "files/${bucket1}/appdata/testapp/data.txt", + "rule": "getAppResourceAccess", + "expects": [ + "READ", + "WRITE" + ] + }, + { + "name": "app-denied-another-apps-appdata", + "subject": "appdataApp", + "resource": "files/${bucket1}/appdata/otherapp/data.txt", + "rule": "getAppResourceAccess", + "expects": [] + }, + { + "name": "app-reads-per-request-shared", + "subject": "appWithPerRequestShare", + "resource": "conversations/${bucket2}/access/second", + "rule": "getPerRequestPermissions", + "expects": [ + "READ" + ] + }, + { + "name": "app-denied-without-per-request-share", + "subject": "appdataApp", + "resource": "conversations/${bucket2}/access/second", + "rule": "getPerRequestPermissions", + "expects": [] + } + ] +}