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
1 change: 1 addition & 0 deletions CedarJava/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* Added PolicySet to JSON conversion API [#329](https://github.com/cedar-policy/cedar-java/pull/329)
* Added Cedar Schema support for Entity Validation [#332](https://github.com/cedar-policy/cedar-java/pull/332)
* Added `PolicyParseException`, thrown by `PolicySet.parsePolicies` when policy text fails to parse. It is a subclass of `InternalException`, so existing `catch` blocks and `getMessage()` are unaffected, and adds `getDetailedErrors()` returning Cedar's structured diagnostics - source span, expected tokens, and help text - for each error. `getErrors()` now carries one entry per parse error rather than a single entry for the whole document [#367](https://github.com/cedar-policy/cedar-java/pull/367)
* Added `Schema.fromFragments`, which combines schema fragments in the JSON and Cedar formats into one schema using Cedar's `Schema::from_schema_fragments` [#1](https://github.com/Vedthakar/cedar-java/pull/1)

## 4.3.1
### Added
Expand Down
31 changes: 31 additions & 0 deletions CedarJava/src/main/java/com/cedarpolicy/model/schema/Schema.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@

package com.cedarpolicy.model.schema;

import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;

import com.cedarpolicy.CedarJson;
import com.cedarpolicy.SharedCedarInternals;
import com.cedarpolicy.loader.LibraryLoader;
import com.cedarpolicy.model.exception.CacheException;
Expand Down Expand Up @@ -131,6 +134,32 @@ public static Schema parse(JsonOrCedar type, String str) throws InternalExceptio

}

/**
* Combine schema fragments into a single schema, using Cedar's
* {@code Schema::from_schema_fragments}. Fragments may mix the JSON and Cedar
* formats, may refer to types declared in other fragments, and may each add
* declarations to the same namespace.
*
* @param fragments Schema fragments to combine.
* @throws InternalException If a fragment fails to parse, or the fragments
* do not form a valid schema (for example, two
* fragments declare the same entity type).
* @throws NullPointerException If the list or any fragment is null.
* @return A {@link Schema} in the JSON format that is guaranteed to be valid.
*/
public static Schema fromFragments(List<Schema> fragments) throws InternalException, NullPointerException {
Objects.requireNonNull(fragments, "fragments");
fragments.forEach(fragment -> Objects.requireNonNull(fragment, "fragment"));
final String fragmentsJson;
try {
fragmentsJson = CedarJson.objectWriter().writeValueAsString(fragments);
} catch (JsonProcessingException e) {
throw new InternalException("Failed to serialize schema fragments: " + e.getMessage());
}
String schemaJson = fromFragmentsJni(fragmentsJson);
return new Schema(JsonOrCedar.Json, Optional.of(schemaJson), Optional.empty());
}

/**
* Converts a schema to Cedar format
*
Expand Down Expand Up @@ -264,6 +293,8 @@ public void run() {

private static native String parseCedarSchemaJni(String schemaText) throws InternalException, NullPointerException;

private static native String fromFragmentsJni(String fragmentsJson) throws InternalException, NullPointerException;

private static native void preparseSchemaJni(String id, String schemaValue, boolean isCedar) throws InternalException;

private static native void removeCachedSchemaJni(String id);
Expand Down
130 changes: 130 additions & 0 deletions CedarJava/src/test/java/com/cedarpolicy/SchemaTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,35 @@

package com.cedarpolicy;

import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;

import static com.cedarpolicy.TestUtil.loadSchemaResource;
import static com.cedarpolicy.TestUtil.loadCedarSchemaResource;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import com.cedarpolicy.model.AuthorizationRequest;
import com.cedarpolicy.model.AuthorizationResponse;
import com.cedarpolicy.model.AuthorizationSuccessResponse.Decision;
import com.cedarpolicy.model.ValidationRequest;
import com.cedarpolicy.model.ValidationResponse;
import com.cedarpolicy.model.exception.InternalException;
import com.cedarpolicy.model.policy.PolicySet;
import com.cedarpolicy.model.schema.Schema;
import com.cedarpolicy.model.schema.Schema.JsonOrCedar;
import com.cedarpolicy.value.EntityTypeName;
import com.cedarpolicy.value.EntityUID;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

Expand Down Expand Up @@ -321,4 +335,120 @@ void testEnumSchemaFormatConversion() throws Exception {
assertNotNull(cedarResult, "Cedar conversion result should not be null");
}
}

@Nested
@DisplayName("fromFragments Tests")
class FromFragmentsTests {
// Each fragment refers to types declared in the other one.
private final Schema usersFragment = new Schema(JsonOrCedar.Json, Optional.of("""
{
"App": {
"entityTypes": {
"User": { "memberOfTypes": ["Group"] },
"Group": {}
},
"actions": {}
}
}
"""), Optional.empty());
private final Schema documentsFragment = new Schema("""
namespace App {
entity Document;
action view appliesTo { principal: [User], resource: [Document] };
}
""");

private final AuthorizationEngine engine = new BasicAuthorizationEngine();

private EntityUID uid(String type, String id) {
return new EntityUID(EntityTypeName.parse(type).get(), id);
}

@Test
void testCombinesJsonAndCedarFragments() throws Exception {
Schema schema = Schema.fromFragments(List.of(usersFragment, documentsFragment));

assertEquals(JsonOrCedar.Json, schema.type);
JsonNode entityTypes = schema.toJsonFormat().get("App").get("entityTypes");
assertTrue(entityTypes.has("User"));
assertTrue(entityTypes.has("Group"));
assertTrue(entityTypes.has("Document"));
assertTrue(schema.toJsonFormat().get("App").get("actions").has("view"));
assertDoesNotThrow(() -> Schema.parse(JsonOrCedar.Json, schema.schemaJson.get().toString()));
}

@Test
void testValidatesPoliciesAgainstCombinedSchema() throws Exception {
Schema schema = Schema.fromFragments(List.of(usersFragment, documentsFragment));

PolicySet valid = PolicySet.parsePolicies(
"permit(principal in App::Group::\"admins\", action == App::Action::\"view\", resource);");
ValidationResponse validResponse = engine.validate(new ValidationRequest(schema, valid));
assertTrue(validResponse.success.get().validationErrors.isEmpty());

PolicySet invalid = PolicySet.parsePolicies(
"permit(principal, action == App::Action::\"edit\", resource);");
ValidationResponse invalidResponse = engine.validate(new ValidationRequest(schema, invalid));
assertFalse(invalidResponse.success.get().validationErrors.isEmpty());
}

@Test
void testAuthorizesWithCombinedSchema() throws Exception {
Schema schema = Schema.fromFragments(List.of(usersFragment, documentsFragment));
PolicySet policies = PolicySet.parsePolicies("permit(principal, action, resource);");
AuthorizationRequest request = new AuthorizationRequest(uid("App::User", "alice"),
uid("App::Action", "view"), uid("App::Document", "doc"), Optional.of(new HashMap<>()),
Optional.of(schema), true);

AuthorizationResponse uncached = engine.isAuthorized(request, policies, new HashSet<>());
assertEquals(Decision.Allow, uncached.success.get().getDecision());

schema.cache();
policies.cache();
AuthorizationResponse cached = engine.isAuthorized(request, policies, new HashSet<>());
assertEquals(Decision.Allow, cached.success.get().getDecision());

// Request validation uses the combined schema: Group is not a valid principal for view.
AuthorizationRequest invalidRequest = new AuthorizationRequest(uid("App::Group", "admins"),
uid("App::Action", "view"), uid("App::Document", "doc"), Optional.of(new HashMap<>()),
Optional.of(schema), true);
AuthorizationResponse rejected = engine.isAuthorized(invalidRequest, policies, new HashSet<>());
assertTrue(rejected.errors.isPresent());
}

@Test
void testSingleAndEmptyFragmentLists() throws Exception {
Schema single = Schema.fromFragments(List.of(documentsFragment, new Schema("namespace App { entity User; }")));
assertTrue(single.toJsonFormat().get("App").get("entityTypes").has("User"));
assertEquals("{}", Schema.fromFragments(List.of()).schemaJson.get().toString());
}

@Test
void testDuplicateDeclarationFails() {
InternalException e = assertThrows(InternalException.class, () -> Schema.fromFragments(List.of(
new Schema("namespace App { entity User; }"), new Schema("namespace App { entity User; }"))));
assertTrue(e.getMessage().contains("App::User"), e.getMessage());
}

@Test
void testUndeclaredTypeFails() {
// documentsFragment refers to App::User, which only usersFragment declares.
InternalException e = assertThrows(InternalException.class,
() -> Schema.fromFragments(List.of(documentsFragment)));
assertTrue(e.getMessage().contains("failed to resolve type: User"), e.getMessage());
}

@Test
void testMalformedFragmentFails() {
assertThrows(InternalException.class,
() -> Schema.fromFragments(List.of(usersFragment, new Schema("not a schema"))));
}

@Test
void testNullInputs() {
assertThrows(NullPointerException.class, () -> Schema.fromFragments(null));
assertThrows(NullPointerException.class,
() -> Schema.fromFragments(Arrays.asList(usersFragment, null)));
}
}
}
Loading
Loading