diff --git a/docs/cedarling/reference/cedarling-policy-store.md b/docs/cedarling/reference/cedarling-policy-store.md index 0c69d61f589..c4c4b131e0b 100644 --- a/docs/cedarling/reference/cedarling-policy-store.md +++ b/docs/cedarling/reference/cedarling-policy-store.md @@ -21,7 +21,242 @@ For a comprehensive JSON schema defining the structure of the policy store, see: **Note:** The `cedarling_store.json` file is only needed if the bootstrap properties: `CEDARLING_LOCK`; `CEDARLING_POLICY_STORE_URI`; and `CEDARLING_POLICY_STORE_ID` are not set to a local location. If you're fetching the policies remotely, you don't need a `cedarling_store.json` file. -## JSON Schema +## Policy Store Formats + +Cedarling supports two policy store formats and automatically detects the correct format based on file extension or URL: + +| Configuration | Detection | +|---------------|-----------| +| `CEDARLING_POLICY_STORE_URI` ending in `.cjar` | Cedar Archive from URL | +| `CEDARLING_POLICY_STORE_URI` (other) | Legacy JSON from Lock Server | +| `CEDARLING_POLICY_STORE_LOCAL_FN` pointing to directory | Directory-based format | +| `CEDARLING_POLICY_STORE_LOCAL_FN` with `.cjar` extension | Cedar Archive file | +| `CEDARLING_POLICY_STORE_LOCAL_FN` with `.json` extension | JSON file | +| `CEDARLING_POLICY_STORE_LOCAL_FN` with `.yaml`/`.yml` extension | YAML file | + +### 1. Legacy Single-File Format (JSON/YAML) + +The original format stores all policies and schema in a single JSON or YAML file with Base64-encoded content. This is documented in detail in the sections below. + +### 2. New Directory-Based Format + +The new directory-based format uses human-readable Cedar files organized in a structured directory: + +```text +policy-store/ +├── metadata.json # Required: Store identification and versioning +├── manifest.json # Optional: File checksums for integrity validation +├── schema.cedarschema # Required: Cedar schema in human-readable format +├── policies/ # Required: Directory containing .cedar policy files +│ ├── allow-read.cedar +│ └── deny-guest.cedar +├── templates/ # Optional: Directory containing .cedar template files +├── entities/ # Optional: Directory containing .json entity files +└── trusted-issuers/ # Optional: Directory containing .json issuer configs +``` + +#### metadata.json + +Contains policy store identification and versioning: + +```json +{ + "cedar_version": "4.4.0", + "policy_store": { + "id": "abc123def456", + "name": "My Application Policies", + "description": "Optional description", + "version": "1.0.0", + "created_date": "2024-01-01T00:00:00Z", + "updated_date": "2024-01-02T00:00:00Z" + } +} +``` + +#### manifest.json (Optional) + +Provides integrity validation with file checksums: + +```json +{ + "policy_store_id": "abc123def456", + "generated_date": "2024-01-01T12:00:00Z", + "files": { + "metadata.json": { + "size": 245, + "checksum": "sha256:abc123..." + }, + "schema.cedarschema": { + "size": 1024, + "checksum": "sha256:def456..." + } + } +} +``` + +When a manifest is present, Cedarling validates: + +- File checksums match (SHA-256) +- File sizes match +- Policy store ID matches between manifest and metadata + +#### Policy Files + +Policies are stored as human-readable `.cedar` files in the `policies/` directory: + +```cedar +@id("allow-read") +permit( + principal, + action == MyApp::Action::"read", + resource +); +``` + +Each policy file must have an `@id` annotation that uniquely identifies the policy. + +#### Template Files + +Templates are stored as human-readable `.cedar` files in the `templates/` directory: + +```cedar +@id("resource-access-template") +permit( + principal == ?principal, + action, + resource == ?resource +); +``` + +Each template file must have an `@id` annotation and use Cedar's template slot syntax (`?principal`, `?resource`). + +#### Entity Files + +Entity files in the `entities/` directory use the Cedar JSON entity format as a **JSON array**. Each file can contain one or more entity definitions: + +```json +[ + { + "uid": { + "type": "Jans::Organization", + "id": "acme-dolphins" + }, + "attrs": { + "name": "Acme Dolphins Division", + "org_id": "100129", + "domain": "acme-dolphin.sea", + "regions": ["Atlantic", "Pacific", "Indian"] + }, + "parents": [] + }, + { + "uid": { + "type": "Jans::Role", + "id": "admin" + }, + "attrs": { + "name": "Administrator", + "permissions": ["read", "write", "delete"] + }, + "parents": [] + } +] +``` + +Each entity requires: + +- **`uid`**: Object with `type` (Cedar entity type name, e.g., `"Jans::Organization"`) and `id` (unique entity identifier) +- **`attrs`**: Object containing entity attributes matching your Cedar schema +- **`parents`**: Optional array of parent entity references for hierarchical relationships + +Example with parent relationships (`entities/users.json`): + +```json +[ + { + "uid": { + "type": "Jans::User", + "id": "alice" + }, + "attrs": { + "name": "Alice Smith", + "email": "alice@example.com" + }, + "parents": [ + {"type": "Jans::Role", "id": "admin"}, + {"type": "Jans::Organization", "id": "acme-dolphins"} + ] + } +] +``` + +#### Trusted Issuer Files + +Trusted issuer configuration files in the `trusted-issuers/` directory define identity providers that can issue tokens. Each file contains a JSON object mapping issuer IDs to their configurations: + +```json +{ + "jans_issuer": { + "name": "Jans Server", + "description": "Primary Janssen Identity Provider", + "openid_configuration_endpoint": "https://jans.example.com/.well-known/openid-configuration", + "token_metadata": { + "access_token": { + "trusted": true, + "entity_type_name": "Jans::Access_token", + "token_id": "jti", + "workload_id": "aud" + }, + "id_token": { + "trusted": true, + "entity_type_name": "Jans::Id_token", + "user_id": "sub", + "role_mapping": "role" + } + } + } +} +``` + +Each trusted issuer configuration includes: + +- **`name`**: Human-readable name for the issuer (used as namespace for `TrustedIssuer` entity) +- **`description`**: Optional description of the issuer +- **`openid_configuration_endpoint`**: HTTPS URL for the OpenID Connect discovery endpoint +- **`token_metadata`**: Map of token types to their metadata configuration (see [Token Metadata Schema](#token-metadata-schema)) + +You can define multiple issuers in a single file or split them across multiple files in the `trusted-issuers/` directory. + +#### Cedar Archive (.cjar) Format + +The directory structure can be packaged as a `.cjar` file (ZIP archive) for distribution: + +```bash +# Create a .cjar archive from a policy store directory +cd policy-store && zip -r ../policy-store.cjar . +``` + +**Note:** In WASM environments, only URL-based and inline string sources are available. Use `CEDARLING_POLICY_STORE_URI` with a `.cjar` URL or `init_from_archive_bytes()` for custom fetch scenarios. + +## Advanced: Loading from Bytes + +For scenarios requiring custom fetch logic (e.g., auth headers), archive bytes can be loaded directly: + +- **WASM**: Use `init_from_archive_bytes(config, bytes)` function +- **Rust**: Use `PolicyStoreSource::ArchiveBytes(Vec)` or `load_policy_store_archive_bytes()` function + +```javascript +// WASM example with custom fetch +const response = await fetch(url, { headers: { Authorization: "..." } }); +const bytes = new Uint8Array(await response.arrayBuffer()); +const cedarling = await init_from_archive_bytes(config, bytes); +``` + +## Legacy Single-File Format (JSON) + +The following sections document the legacy single-file JSON format. + +### JSON Schema The JSON Schema accepted by Cedarling is defined as follows: @@ -510,9 +745,9 @@ entity Tokens = { The naming follows this pattern: -- **Issuer name**: From trusted issuer metadata `name` field, or hostname from JWT `iss` claim -- **Token type**: Extracted from the `mapping` field (e.g., "Jans::Access_Token" → "access_token") -- Both converted to lowercase with underscores replacing special characters +- **Issuer name**: From trusted issuer metadata `name` field, or hostname from JWT `iss` claim +- **Token type**: Extracted from the `mapping` field (e.g., "Jans::Access_Token" → "access_token") +- Both converted to lowercase with underscores replacing special characters ### Schema Requirements for Multi-Issuer diff --git a/docs/cedarling/reference/cedarling-properties.md b/docs/cedarling/reference/cedarling-properties.md index 34d3e0b02ee..0664ba4cecc 100644 --- a/docs/cedarling/reference/cedarling-properties.md +++ b/docs/cedarling/reference/cedarling-properties.md @@ -26,12 +26,24 @@ To load policy store one of the following keys must be provided: - **`CEDARLING_POLICY_STORE_LOCAL`** : JSON object as string with policy store. You can use [this](https://jsontostring.com/) converter. -- **`CEDARLING_POLICY_STORE_URI`** : Location of policy store JSON, used if policy store is not local, or retrieved from Lock Master. +- **`CEDARLING_POLICY_STORE_URI`** : URL to fetch policy store from. Cedarling automatically detects the format: + - URLs ending in `.cjar` → loads as Cedar Archive + - Other URLs → loads as legacy JSON from Lock Server -- **`CEDARLING_POLICY_STORE_LOCAL_FN`** : Path to a Policy Store JSON file +- **`CEDARLING_POLICY_STORE_LOCAL_FN`** : Path to local policy store. Cedarling automatically detects the format: + - Directories → loads as directory-based policy store + - `.cjar` files → loads as Cedar Archive + - `.json` files → loads as JSON + - `.yaml`/`.yml` files → loads as YAML + +**New Directory-Based Format** (Native platforms only): + +Cedarling now supports a directory-based policy store format with human-readable Cedar files. See [Policy Store Formats](./cedarling-policy-store.md#policy-store-formats) for details. + +**Note:** In WASM environments, only `CEDARLING_POLICY_STORE_URI` and `CEDARLING_POLICY_STORE_LOCAL` are available. File and directory sources (`CEDARLING_POLICY_STORE_LOCAL_FN`) are not supported in WASM due to lack of filesystem access. !!! NOTE - All other fields are optional and can be omitted. If a field is not provided, Cedarling will use the default value specified in the property definition. +All other fields are optional and can be omitted. If a field is not provided, Cedarling will use the default value specified in the property definition. **Auxilliary properties** diff --git a/docs/cedarling/tutorials/go.md b/docs/cedarling/tutorials/go.md index 025980fa6f4..399642eaa46 100644 --- a/docs/cedarling/tutorials/go.md +++ b/docs/cedarling/tutorials/go.md @@ -13,8 +13,8 @@ Go bindings for the Jans Cedarling authorization engine, providing policy-based ### Build with dynamic linking -1. Download the appropriate pre-built binary for your platform from the Jans releases page or build it from source as -described above. +1. Download the appropriate pre-built binary for your platform from the Jans releases page or build it from source as + described above. 2. Specify linker flags in your main.go file to link against the Cedarling library. @@ -42,14 +42,14 @@ described above. - **Windows** - Place the Rust artifacts (`cedarling_go.dll` and `cedarling_go.lib`) alongside the Go binary. - - Windows searches libraries in directories below in the + - Windows searches libraries in directories below in the following order 1. The directory containing your Go executable (recommended location) 2. Windows system directories (e.g., `C:\Windows\System32`) 3. The `PATH` environment variable directories - **Linux** - + Add the library directory that contains `libcedarling_go.so` to the `LD_LIBRARY_PATH` environment variable @@ -58,10 +58,10 @@ described above. ``` - **MacOS** - + Add the library directory that contains `libcedarling_go.dylib` to the `LD_LIBRARY_PATH` environment variable - + ```sh export DYLD_LIBRARY_PATH=$(pwd):$DYLD_LIBRARY_PATH ``` @@ -147,6 +147,37 @@ if err != nil { } ``` +### Policy Store Sources + +Go bindings support all native policy store source types. See [Cedarling Properties](../reference/cedarling-properties.md) for the full list of configuration options. + +**Example configurations:** + +```go +// Load from a directory +config := map[string]any{ + "CEDARLING_APPLICATION_NAME": "MyApp", + "CEDARLING_POLICY_STORE_LOCAL_FN": "/path/to/policy-store/", + // ... other config +} + +// Load from a local .cjar archive (Cedar Archive) +config := map[string]any{ + "CEDARLING_APPLICATION_NAME": "MyApp", + "CEDARLING_POLICY_STORE_LOCAL_FN": "/path/to/policy-store.cjar", + // ... other config +} + +// Load from a remote .cjar archive (Cedar Archive) +config := map[string]any{ + "CEDARLING_APPLICATION_NAME": "MyApp", + "CEDARLING_POLICY_STORE_URI": "https://example.com/policy-store.cjar", + // ... other config +} +``` + +See [Policy Store Formats](../reference/cedarling-policy-store.md#policy-store-formats) for more details. + ### Authorization Cedarling provides two main interfaces for performing authorization checks: **Token-Based Authorization** and **Unsigned Authorization**. Both methods involve evaluating access requests based on various factors, including principals (entities), actions, resources, and context. The difference lies in how the Principals are provided. @@ -154,7 +185,6 @@ Cedarling provides two main interfaces for performing authorization checks: **To - [**Token-Based Authorization**](#token-based-authorization) is the standard method where principals are extracted from JSON Web Tokens (JWTs), typically used in scenarios where you have existing user authentication and authorization data encapsulated in tokens. - [**Unsigned Authorization**](#unsigned-authorization) allows you to pass principals directly, bypassing tokens entirely. This is useful when you need to authorize based on internal application data, or when tokens are not available. - #### Token-Based Authorization **1. Define the resource:** @@ -318,10 +348,10 @@ if err != nil { if result.Decision { fmt.Println("Access granted") fmt.Printf("Request ID: %s\n", result.RequestID) - + // Access detailed Cedar response fmt.Printf("Cedar decision: %s\n", result.Response.Decision().ToString()) - + // Get diagnostic information diagnostics := result.Response.Diagnostics() if len(diagnostics.Reason()) > 0 { diff --git a/docs/cedarling/tutorials/java.md b/docs/cedarling/tutorials/java.md index cf5b79f5515..7204899260e 100644 --- a/docs/cedarling/tutorials/java.md +++ b/docs/cedarling/tutorials/java.md @@ -40,7 +40,6 @@ To use Cedarling Java bindings in Java Maven Project add following Refer to the following [guide](../developer/cedarling-kotlin.md#building-from-source) for steps to build the Java binding from source. - ## Usage ### Initialization @@ -81,6 +80,40 @@ We need to initialize Cedarling first. ``` +### Policy Store Sources + +Java bindings support all native policy store source types. See [Cedarling Properties](../reference/cedarling-properties.md) for the full list of configuration options and [Policy Store Formats](../reference/cedarling-policy-store.md#policy-store-formats) for format details. + +**Example configurations:** + +```java +// Load from a directory +String bootstrapJsonStr = """ + { + "CEDARLING_APPLICATION_NAME": "MyApp", + "CEDARLING_POLICY_STORE_LOCAL_FN": "/path/to/policy-store/" + } + """; + +// Load from a local .cjar archive (Cedar Archive) +String bootstrapJsonStr = """ + { + "CEDARLING_APPLICATION_NAME": "MyApp", + "CEDARLING_POLICY_STORE_LOCAL_FN": "/path/to/policy-store.cjar" + } + """; + +// Load from a remote .cjar archive (Cedar Archive) +String bootstrapJsonStr = """ + { + "CEDARLING_APPLICATION_NAME": "MyApp", + "CEDARLING_POLICY_STORE_URI": "https://example.com/policy-store.cjar" + } + """; +``` + +See [Policy Store Formats](../reference/cedarling-policy-store.md#policy-store-formats) for more details. + ### Authorization Cedarling provides two main interfaces for performing authorization checks: **Token-Based Authorization** and **Unsigned Authorization**. Both methods involve evaluating access requests based on various factors, including principals (entities), actions, resources, and context. The difference lies in how the Principals are provided. @@ -88,7 +121,6 @@ Cedarling provides two main interfaces for performing authorization checks: **To - [**Token-Based Authorization**](#token-based-authorization) is the standard method where principals are extracted from JSON Web Tokens (JWTs), typically used in scenarios where you have existing user authentication and authorization data encapsulated in tokens. - [**Unsigned Authorization**](#unsigned-authorization) allows you to pass principals directly, bypassing tokens entirely. This is useful when you need to authorize based on internal application data, or when tokens are not available. - #### Token-Based Authorization **1. Define the resource:** @@ -228,4 +260,3 @@ Defined APIs are listed [here](https://janssenproject.github.io/developer-docs/j - [Cedarling TBAC quickstart](../quick-start/cedarling-quick-start.md#implement-tbac-using-cedarling) - [Cedarling Unsigned quickstart](../quick-start/cedarling-quick-start.md#step-1-create-the-cedar-policy-and-schema) - diff --git a/docs/cedarling/tutorials/javascript.md b/docs/cedarling/tutorials/javascript.md index 8a84052a7b0..e192f097aa3 100644 --- a/docs/cedarling/tutorials/javascript.md +++ b/docs/cedarling/tutorials/javascript.md @@ -7,12 +7,10 @@ tags: - getting-started --- - # Getting Started with Cedarling in a JavaScript app This guide combines the JavaScript usage instructions with the WebAssembly (WASM) build and API reference for Cedarling. - ## Installation ### Using the package manager @@ -25,10 +23,8 @@ npm i @janssenproject/cedarling_wasm Alternatively, see [here](#build-from-source), if you want to build Cedarling from the source. - ### Build from Source - #### Requirements Rust 1.63 or Greater. Ensure that you have `Rust` version 1.63 or higher installed. @@ -76,8 +72,6 @@ To view the WebAssembly project in action, you can run a local server. One way t python3 -m http.server ``` - - ## Usage !!! info "Sample Apps" @@ -95,7 +89,6 @@ Since Cedarling is a WASM module, you need to initialize it first. ```js import initWasm, { init } from "@janssenproject/cedarling_wasm"; - // initialize the WASM binary await initWasm(); @@ -109,9 +102,46 @@ let cedarling = init( "CEDARLING_WORKLOAD_AUTHZ": "disabled", "CEDARLING_JWT_SIG_VALIDATION": "disabled", "CEDARLING_ID_TOKEN_TRUST_MODE": "never", -); +}); ``` +### Policy Store Sources (WASM) + +In WASM environments, filesystem access is not available. Use one of these options: + +```javascript +// Option 1: URL-based loading (simple) +let cedarling = await init({ + CEDARLING_POLICY_STORE_URI: "https://example.com/policy-store.cjar", + // ... other config +}); + +// Option 2: Inline JSON string +let cedarling = await init({ + CEDARLING_POLICY_STORE_LOCAL: JSON.stringify(policyStoreObject), + // ... other config +}); + +// Option 3: Custom fetch with auth headers +import initWasm, { + init_from_archive_bytes, +} from "@janssenproject/cedarling_wasm"; + +const response = await fetch("https://example.com/policy-store.cjar", { + headers: { Authorization: `Bearer ${token}` }, +}); +const bytes = new Uint8Array(await response.arrayBuffer()); +let cedarling = await init_from_archive_bytes(config, bytes); +``` + +For the directory-based format, package your policy store as a `.cjar` file and host it: + +```bash +cd policy-store && zip -r ../policy-store.cjar . +``` + +See [Policy Store Formats](../reference/cedarling-policy-store.md#policy-store-formats) for details. + ### Authorization Cedarling provides two main interfaces for performing authorization checks: **Token-Based Authorization** and **Unsigned Authorization**. Both methods involve evaluating access requests based on various factors, including principals (entities), actions, resources, and context. The difference lies in how the Principals are provided. diff --git a/docs/cedarling/tutorials/python.md b/docs/cedarling/tutorials/python.md index cca073614a5..21f9136b6be 100644 --- a/docs/cedarling/tutorials/python.md +++ b/docs/cedarling/tutorials/python.md @@ -100,6 +100,28 @@ cedarling = Cedarling(bootstrap_config) See the python documentation for `BootstrapConfig` for other config loading options. +### Policy Store Sources + +Python bindings support all policy store source types. See [Cedarling Properties](../reference/cedarling-properties.md) for the full list of configuration options. + +**Example configurations:** + +```py +# Load from a directory +os.environ["CEDARLING_POLICY_STORE_LOCAL_FN"] = "/path/to/policy-store/" +bootstrap_config = BootstrapConfig.from_env() + +# Load from a local .cjar archive (Cedar Archive) +os.environ["CEDARLING_POLICY_STORE_LOCAL_FN"] = "/path/to/policy-store.cjar" +bootstrap_config = BootstrapConfig.from_env() + +# Load from a remote .cjar archive (Cedar Archive) +os.environ["CEDARLING_POLICY_STORE_URI"] = "https://example.com/policy-store.cjar" +bootstrap_config = BootstrapConfig.from_env() +``` + +See [Policy Store Formats](../reference/cedarling-policy-store.md#policy-store-formats) for more details. + ### Authorization Cedarling provides two main interfaces for performing authorization checks: **Token-Based Authorization** and **Unsigned Authorization**. Both methods involve evaluating access requests based on various factors, including principals (entities), actions, resources, and context. The difference lies in how the Principals are provided. @@ -348,13 +370,13 @@ else: **Key Differences from standard authentication**: -| Feature | authorize | authorize_multi_issuer | -|---------|-----------|------------------------| -| Principal Model | User/Workload entities | No principals - token-based | -| Token Sources | Single issuer expected | Multiple issuers supported | -| Result Type | `AuthorizeResult` | `MultiIssuerAuthorizeResult` | -| Decision Access | `result.is_allowed()`, `result.workload()`, `result.person()` | `result.decision` (boolean) | -| Use Case | Standard RBAC/ABAC | Federation, multi-org access | +| Feature | authorize | authorize_multi_issuer | +| --------------- | ------------------------------------------------------------- | ---------------------------- | +| Principal Model | User/Workload entities | No principals - token-based | +| Token Sources | Single issuer expected | Multiple issuers supported | +| Result Type | `AuthorizeResult` | `MultiIssuerAuthorizeResult` | +| Decision Access | `result.is_allowed()`, `result.workload()`, `result.person()` | `result.decision` (boolean) | +| Use Case | Standard RBAC/ABAC | Federation, multi-org access | ### Logging diff --git a/docs/cedarling/tutorials/rust.md b/docs/cedarling/tutorials/rust.md index 900b2d32938..1f1fbfaa1c7 100644 --- a/docs/cedarling/tutorials/rust.md +++ b/docs/cedarling/tutorials/rust.md @@ -80,6 +80,66 @@ let cedarling = Cedarling::new(bootstrap_config) See the [bootstrap properties docs](../reference/cedarling-properties.md) for other config loading options. +### Policy Store Sources + +Rust bindings support all policy store source types: + +| Source Type | Description | +| ------------------------------------------ | -------------------------------- | +| `PolicyStoreSource::Json(String)` | Inline JSON policy store | +| `PolicyStoreSource::Yaml(String)` | Inline YAML policy store | +| `PolicyStoreSource::FileJson(PathBuf)` | Local JSON file | +| `PolicyStoreSource::FileYaml(PathBuf)` | Local YAML file | +| `PolicyStoreSource::Directory(PathBuf)` | Local directory with Cedar files | +| `PolicyStoreSource::CjarFile(PathBuf)` | Local Cedar archive file | +| `PolicyStoreSource::CjarUrl(String)` | Remote `.cjar` archive from URL | +| `PolicyStoreSource::LockServer(String)` | Remote Lock Server | +| `PolicyStoreSource::ArchiveBytes(Vec)` | Raw archive bytes (custom fetch) | + +**Loading from Bytes:** + +For advanced use cases (embedded archives, custom fetch logic): + +```rust +use cedarling::*; + +// Option 1: Via PolicyStoreSource enum (recommended) +let archive_bytes: Vec = fetch_archive_with_auth(); +let config = BootstrapConfig::default() + .with_policy_store_source(PolicyStoreSource::ArchiveBytes(archive_bytes)); + +// Option 2: Direct function call +use cedarling::common::policy_store::loader::load_policy_store_archive_bytes; +let loaded = load_policy_store_archive_bytes(archive_bytes)?; +``` + +**Example programmatic configuration:** + +```rust +use cedarling::*; +use std::path::PathBuf; + +// Load from a directory +let config = BootstrapConfig::default() + .with_policy_store_source(PolicyStoreSource::Directory( + PathBuf::from("/path/to/policy-store/") + )); + +// Load from a local .cjar archive (Cedar Archive) +let config = BootstrapConfig::default() + .with_policy_store_source(PolicyStoreSource::CjarFile( + PathBuf::from("/path/to/policy-store.cjar") + )); + +// Load from a remote .cjar archive (Cedar Archive) +let config = BootstrapConfig::default() + .with_policy_store_source(PolicyStoreSource::CjarUrl( + "https://example.com/policy-store.cjar".to_string() + )); +``` + +See [Policy Store Formats](../reference/cedarling-policy-store.md#policy-store-formats) for more details. + ### Authorization Cedarling provides two main interfaces for performing authorization checks: **Token-Based Authorization** and **Unsigned Authorization**. Both methods involve evaluating access requests based on various factors, including principals (entities), actions, resources, and context. The difference lies in how the Principals are provided. diff --git a/jans-cedarling/bindings/cedarling-java/README.md b/jans-cedarling/bindings/cedarling-java/README.md index 717830ad4ce..bfbce917183 100644 --- a/jans-cedarling/bindings/cedarling-java/README.md +++ b/jans-cedarling/bindings/cedarling-java/README.md @@ -89,6 +89,53 @@ To use Cedarling Java bindings in Java Maven Project add following `repository` ## Configuration +### Policy Store Sources + +Cedarling supports multiple ways to load policy stores: + +#### Legacy Single-File Formats + +```json +{ + "CEDARLING_POLICY_STORE_LOCAL_FN": "/path/to/policy-store.json", + "CEDARLING_POLICY_STORE_URI": "https://lock-server.example.com/policy-store" +} +``` + +#### New Directory-Based Format + +Policy stores can be structured as directories with human-readable Cedar files: + +```text +policy-store/ +├── metadata.json # Required: Store metadata (id, name, version) +├── manifest.json # Optional: File checksums for integrity validation +├── schema.cedarschema # Required: Cedar schema (human-readable) +├── policies/ # Required: .cedar policy files +│ ├── allow-read.cedar +│ └── deny-guest.cedar +├── templates/ # Optional: .cedar template files +├── entities/ # Optional: .json entity files +└── trusted-issuers/ # Optional: .json issuer configurations +``` + +**metadata.json structure:** + +```json +{ + "cedar_version": "4.4.0", + "policy_store": { + "id": "abc123def456", + "name": "My Application Policies", + "version": "1.0.0" + } +} +``` + +#### Cedar Archive (.cjar) Format + +Policy stores can be packaged as `.cjar` files (ZIP archives) for easy distribution. + ### ID Token Trust Mode The `CEDARLING_ID_TOKEN_TRUST_MODE` property controls how ID tokens are validated: diff --git a/jans-cedarling/bindings/cedarling_go/README.md b/jans-cedarling/bindings/cedarling_go/README.md index 75e40a8040b..c500f591640 100644 --- a/jans-cedarling/bindings/cedarling_go/README.md +++ b/jans-cedarling/bindings/cedarling_go/README.md @@ -267,6 +267,39 @@ logs := instance.GetLogsByTag("info") ## Configuration +### Policy Store Sources + +Cedarling supports multiple ways to load policy stores. See [Policy Store Formats](../../../docs/cedarling/reference/cedarling-policy-store.md#policy-store-formats) for complete documentation on all supported formats. + +**Example configurations:** + +```go +// From a local JSON file +config := map[string]any{ + "CEDARLING_POLICY_STORE_LOCAL_FN": "/path/to/policy-store.json", +} + +// From a directory with human-readable Cedar files +config := map[string]any{ + "CEDARLING_POLICY_STORE_LOCAL_FN": "/path/to/policy-store/", +} + +// From a local .cjar archive (Cedar Archive) +config := map[string]any{ + "CEDARLING_POLICY_STORE_LOCAL_FN": "/path/to/policy-store.cjar", +} + +// From a remote .cjar archive +config := map[string]any{ + "CEDARLING_POLICY_STORE_URI": "https://example.com/policy-store.cjar", +} + +// From Lock Server +config := map[string]any{ + "CEDARLING_POLICY_STORE_URI": "https://lock-server.example.com/policy-store", +} +``` + ### ID Token Trust Mode The `CEDARLING_ID_TOKEN_TRUST_MODE` property controls how ID tokens are validated: diff --git a/jans-cedarling/bindings/cedarling_python/PYTHON_TYPES.md b/jans-cedarling/bindings/cedarling_python/PYTHON_TYPES.md index 0cc3f762506..4188cb518e2 100644 --- a/jans-cedarling/bindings/cedarling_python/PYTHON_TYPES.md +++ b/jans-cedarling/bindings/cedarling_python/PYTHON_TYPES.md @@ -4,8 +4,13 @@ This document describes the Cedarling Python bindings types. Documentation was generated from python types. -AuthorizeMultiIssuerRequest -=========================== +## Policy Store Sources + +For details on the new directory-based format and .cjar archives, see [Policy Store Formats](../../../docs/cedarling/reference/cedarling-policy-store.md#policy-store-formats). + +--- + +# AuthorizeMultiIssuerRequest A Python wrapper for the Rust `cedarling::AuthorizeMultiIssuerRequest` struct. Represents a multi-issuer authorization request with multiple JWT tokens from different issuers. diff --git a/jans-cedarling/bindings/cedarling_python/README.md b/jans-cedarling/bindings/cedarling_python/README.md index f5fa414ffdd..f8fb59d94cb 100644 --- a/jans-cedarling/bindings/cedarling_python/README.md +++ b/jans-cedarling/bindings/cedarling_python/README.md @@ -116,6 +116,46 @@ CEDARLING_POLICY_STORE_LOCAL_FN=example_files/policy-store.json python example.p ## Configuration +### Policy Store Sources + +Policy store sources can be configured via a YAML/JSON file or environment variables. Here are examples for each source type: + +```python +from cedarling_python import BootstrapConfig, Cedarling + +# From a local JSON/YAML file +bootstrap_config = BootstrapConfig.load_from_file("/path/to/bootstrap-config.yaml") +instance = Cedarling(bootstrap_config) + +# From a local directory (new format) +# In your bootstrap-config.yaml: +# CEDARLING_POLICY_STORE_LOCAL_FN: "/path/to/policy-store/" +bootstrap_config = BootstrapConfig.load_from_file("/path/to/bootstrap-config.yaml") +instance = Cedarling(bootstrap_config) + +# From a local .cjar archive +# In your bootstrap-config.yaml: +# CEDARLING_POLICY_STORE_LOCAL_FN: "/path/to/policy-store.cjar" +bootstrap_config = BootstrapConfig.load_from_file("/path/to/bootstrap-config.yaml") +instance = Cedarling(bootstrap_config) + +# From a URL (.cjar or Lock Server) +# In your bootstrap-config.yaml: +# CEDARLING_POLICY_STORE_URI: "https://example.com/policy-store.cjar" +bootstrap_config = BootstrapConfig.load_from_file("/path/to/bootstrap-config.yaml") +instance = Cedarling(bootstrap_config) + +# Using environment variables instead of a file +import os +os.environ["CEDARLING_POLICY_STORE_LOCAL_FN"] = "/path/to/policy-store.json" +bootstrap_config = BootstrapConfig.from_env() +instance = Cedarling(bootstrap_config) +``` + +For a complete working example showing the full instantiation flow, see [`example.py`](example.py). + +For details on the directory-based format and .cjar archives, see [Policy Store Formats](../../../docs/cedarling/reference/cedarling-policy-store.md#policy-store-formats). + ### ID Token Trust Mode The `CEDARLING_ID_TOKEN_TRUST_MODE` property controls how ID tokens are validated: diff --git a/jans-cedarling/bindings/cedarling_uniffi/README.md b/jans-cedarling/bindings/cedarling_uniffi/README.md index 88d6f4d5769..202b08de355 100644 --- a/jans-cedarling/bindings/cedarling_uniffi/README.md +++ b/jans-cedarling/bindings/cedarling_uniffi/README.md @@ -164,6 +164,57 @@ The method will execute the steps for Cedarling initialization with a sample boo ## Configuration +### Policy Store Sources + +Cedarling supports multiple ways to load policy stores: + +#### Legacy Single-File Formats + +```json +{ + "CEDARLING_POLICY_STORE_LOCAL_FN": "/path/to/policy-store.json", + "CEDARLING_POLICY_STORE_URI": "https://lock-server.example.com/policy-store" +} +``` + +#### New Directory-Based Format + +Policy stores can be structured as directories with human-readable Cedar files: + +```text +policy-store/ +├── metadata.json # Required: Store metadata (id, name, version) +├── manifest.json # Optional: File checksums for integrity validation +├── schema.cedarschema # Required: Cedar schema (human-readable) +├── policies/ # Required: .cedar policy files +│ ├── allow-read.cedar +│ └── deny-guest.cedar +├── templates/ # Optional: .cedar template files +├── entities/ # Optional: .json entity files +└── trusted-issuers/ # Optional: .json issuer configurations +``` + +**metadata.json structure:** + +```json +{ + "cedar_version": "4.4.0", + "policy_store": { + "id": "abc123def456", + "name": "My Application Policies", + "version": "1.0.0" + } +} +``` + +#### Cedar Archive (.cjar) Format + +Policy stores can be packaged as `.cjar` files (ZIP archives) for easy distribution: + +- Single file for versioning and deployment +- Works across all platforms +- Supports integrity validation via manifest + ### ID Token Trust Mode The `CEDARLING_ID_TOKEN_TRUST_MODE` property controls how ID tokens are validated: diff --git a/jans-cedarling/bindings/cedarling_wasm/README.md b/jans-cedarling/bindings/cedarling_wasm/README.md index 4bc0dcd7560..c4f21be3889 100644 --- a/jans-cedarling/bindings/cedarling_wasm/README.md +++ b/jans-cedarling/bindings/cedarling_wasm/README.md @@ -70,6 +70,25 @@ Before using any function from library you need initialize WASM runtime by calli */ export function init(config: any): Promise; +/** + * Create a new instance of the Cedarling application from archive bytes. + * + * This function allows loading a policy store from a Cedar Archive (.cjar) + * that was fetched with custom logic (e.g., with authentication headers). + * + * # Arguments + * * `config` - Bootstrap configuration (Map or Object). Policy store config is ignored. + * * `archive_bytes` - The .cjar archive bytes (Uint8Array) + * + * # Example + * ```javascript + * const response = await fetch(url, { headers: { Authorization: 'Bearer ...' } }); + * const bytes = new Uint8Array(await response.arrayBuffer()); + * const cedarling = await init_from_archive_bytes(config, bytes); + * ``` + */ +export function init_from_archive_bytes(config: any, archive_bytes: Uint8Array): Promise; + /** * The instance of the Cedarling application. */ @@ -248,6 +267,50 @@ export class PolicyEvaluationError { ## Configuration +### Policy Store Sources + +Cedarling supports multiple ways to load policy stores. **In WASM environments, only URL-based loading is available** (no filesystem access). + +#### WASM-Supported Options + +```javascript +// Option 1: Fetch policy store from URL (simple) +const BOOTSTRAP_CONFIG = { + CEDARLING_POLICY_STORE_URI: "https://example.com/policy-store.cjar", + // ... other config +}; +const cedarling = await init(BOOTSTRAP_CONFIG); + +// Option 2: Inline JSON string (for embedded policy stores) +// policyStoreJson is the policy store JSON as a string +// See: https://docs.jans.io/stable/cedarling/reference/cedarling-policy-store/ +const policyStoreJson = '{"cedar_version":"4.0","policy_stores":{...}}'; +const BOOTSTRAP_CONFIG = { + CEDARLING_POLICY_STORE_LOCAL: policyStoreJson, + // ... other config +}; +const cedarling = await init(BOOTSTRAP_CONFIG); + +// Option 3: Custom fetch with auth headers (use init_from_archive_bytes) +const response = await fetch("https://example.com/policy-store.cjar", { + headers: { Authorization: `Bearer ${token}` }, +}); +const bytes = new Uint8Array(await response.arrayBuffer()); +const cedarling = await init_from_archive_bytes(BOOTSTRAP_CONFIG, bytes); +``` + +> **Note:** Directory-based loading and file-based loading are **NOT supported in WASM** (no filesystem access). Use URL-based loading or `init_from_archive_bytes` for custom fetch scenarios. + +#### Cedar Archive (.cjar) Format + +For the new directory-based format in WASM, package the directory structure as a `.cjar` file (ZIP archive): + +```bash +cd policy-store && zip -r ../policy-store.cjar . +``` + +See [Policy Store Formats](../../../docs/cedarling/reference/cedarling-policy-store.md#policy-store-formats) for details on the directory structure and metadata.json format. + ### ID Token Trust Mode The `CEDARLING_ID_TOKEN_TRUST_MODE` property controls how ID tokens are validated: @@ -271,8 +334,4 @@ const BOOTSTRAP_CONFIG = { }; ``` -For complete configuration documentation, see [cedarling-properties.md](../../../docs/cedarling/cedarling-properties.md) or on [our page](https://docs.jans.io/stable/cedarling/cedarling-properties/) . - -``` - -``` +For complete configuration documentation, see [cedarling-properties.md](../../../docs/cedarling/cedarling-properties.md) or on [our page](https://docs.jans.io/stable/cedarling/cedarling-properties/). diff --git a/jans-cedarling/bindings/cedarling_wasm/example_data.js b/jans-cedarling/bindings/cedarling_wasm/example_data.js index b458a252ae2..b84c3984030 100644 --- a/jans-cedarling/bindings/cedarling_wasm/example_data.js +++ b/jans-cedarling/bindings/cedarling_wasm/example_data.js @@ -1,5 +1,30 @@ +/** + * Bootstrap Configuration for Cedarling WASM + * + * POLICY STORE LOADING (WASM): + * ============================ + * + * Option 1: URL-based loading (simple) + * Use CEDARLING_POLICY_STORE_URI to fetch from a URL. + * const cedarling = await init(config); + * + * Option 2: Inline JSON string + * Use CEDARLING_POLICY_STORE_LOCAL for embedded policy stores. + * const cedarling = await init(config); + * + * Option 3: Custom fetch with auth headers + * Use init_from_archive_bytes() for advanced scenarios: + * const response = await fetch(url, { headers: { Authorization: '...' } }); + * const bytes = new Uint8Array(await response.arrayBuffer()); + * const cedarling = await init_from_archive_bytes(config, bytes); + * + * NOT SUPPORTED IN WASM: + * - CEDARLING_POLICY_STORE_LOCAL_FN (requires filesystem) + * - Directory/CjarFile sources (requires filesystem) + */ const BOOTSTRAP_CONFIG = { CEDARLING_APPLICATION_NAME: "My App", + // Policy store URL - can be JSON, YAML, or .cjar archive CEDARLING_POLICY_STORE_URI: "https://raw.githubusercontent.com/JanssenProject/jans/refs/heads/main/jans-cedarling/bindings/cedarling_python/example_files/policy-store.json", CEDARLING_LOG_TYPE: "memory", diff --git a/jans-cedarling/bindings/cedarling_wasm/src/lib.rs b/jans-cedarling/bindings/cedarling_wasm/src/lib.rs index cc3cc7b0436..677523b1c18 100644 --- a/jans-cedarling/bindings/cedarling_wasm/src/lib.rs +++ b/jans-cedarling/bindings/cedarling_wasm/src/lib.rs @@ -14,7 +14,7 @@ use serde_wasm_bindgen::Error; use std::collections::HashMap; use std::rc::Rc; use wasm_bindgen::prelude::*; -use wasm_bindgen_futures::js_sys::{Array, Map, Object, Reflect}; +use wasm_bindgen_futures::js_sys::{self, Array, Map, Object, Reflect}; #[cfg(test)] mod tests; @@ -80,6 +80,61 @@ pub async fn init(config: JsValue) -> Result { } } +/// Create a new instance of the Cedarling application from archive bytes. +/// +/// This function allows loading a policy store from a Cedar Archive (.cjar) +/// that was fetched with custom logic (e.g., with authentication headers). +/// +/// # Arguments +/// * `config` - Bootstrap configuration (Map or Object). Policy store config is ignored. +/// * `archive_bytes` - The .cjar archive bytes (Uint8Array) +/// +/// # Example +/// ```javascript +/// const response = await fetch(url, { headers: { Authorization: 'Bearer ...' } }); +/// const bytes = new Uint8Array(await response.arrayBuffer()); +/// const cedarling = await init_from_archive_bytes(config, bytes); +/// ``` +#[wasm_bindgen] +pub async fn init_from_archive_bytes( + config: JsValue, + archive_bytes: js_sys::Uint8Array, +) -> Result { + use cedarling::PolicyStoreSource; + + // Convert Uint8Array to Vec + let bytes: Vec = archive_bytes.to_vec(); + + // Parse the config + let config_object = if config.is_instance_of::() { + let config_map: Map = config.unchecked_into(); + Object::from_entries(&config_map.unchecked_into())? + } else if let Some(obj) = Object::try_from(&config) { + obj.clone() + } else { + return Err(Error::new("config should be Map or Object")); + }; + + let mut raw_config: BootstrapConfigRaw = serde_wasm_bindgen::from_value(config_object.into())?; + + // Clear any existing policy store sources to avoid conflicts + // We'll set a dummy source temporarily to satisfy validation, then override with ArchiveBytes + raw_config.local_policy_store = None; + raw_config.policy_store_uri = None; + // Set a dummy .cjar file path to satisfy validation (will be overridden below) + raw_config.policy_store_local_fn = Some("dummy.cjar".to_string()); + + let mut bootstrap_config = BootstrapConfig::from_raw_config(&raw_config).map_err(Error::new)?; + + // Override the policy store source with the archive bytes + bootstrap_config.policy_store_config.source = PolicyStoreSource::ArchiveBytes(bytes); + + cedarling::Cedarling::new(&bootstrap_config) + .await + .map(|instance| Cedarling { instance }) + .map_err(Error::new) +} + #[wasm_bindgen] impl Cedarling { /// Create a new instance of the Cedarling application. diff --git a/jans-cedarling/cedarling/Cargo.toml b/jans-cedarling/cedarling/Cargo.toml index 9c68fb9bbce..f4a28bc778a 100644 --- a/jans-cedarling/cedarling/Cargo.toml +++ b/jans-cedarling/cedarling/Cargo.toml @@ -47,6 +47,10 @@ futures = "0.3.31" wasm-bindgen-futures = { workspace = true } config = "0.15.11" ahash = { version = "0.8.12", default-features = false, features = ["no-rng"] } +vfs = "0.12" +hex = "0.4.3" +sha2 = "0.10.8" +zip = "7.0.0" [target.'cfg(target_arch = "wasm32")'.dependencies] web-sys = { workspace = true, features = ["console"] } @@ -61,6 +65,7 @@ mockito = { workspace = true } criterion = { version = "0.5.1", features = ["async_tokio"] } tokio = { workspace = true, features = ["rt-multi-thread"] } stats_alloc = "0.1.10" +tempfile = "3.8" [target.'cfg(not(any(target_arch = "wasm32", target_os = "windows")))'.dev-dependencies] pprof = { version = "0.14.0", features = ["flamegraph"] } @@ -77,3 +82,7 @@ harness = false [[bench]] name = "authz_authorize_multi_issuer_benchmark" harness = false + +[[bench]] +name = "policy_store_benchmark" +harness = false diff --git a/jans-cedarling/cedarling/benches/policy_store_benchmark.rs b/jans-cedarling/cedarling/benches/policy_store_benchmark.rs new file mode 100644 index 00000000000..46e6fd85d12 --- /dev/null +++ b/jans-cedarling/cedarling/benches/policy_store_benchmark.rs @@ -0,0 +1,305 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Policy store loading and validation benchmarks. +//! +//! Run with: `cargo bench --bench policy_store_benchmark` + +use std::hint::black_box as bb; +use std::io::{Cursor, Write}; + +use criterion::{BatchSize, BenchmarkId, Criterion, criterion_group, criterion_main}; +use tempfile::TempDir; +use zip::write::{ExtendedFileOptions, FileOptions}; +use zip::{CompressionMethod, ZipWriter}; + +// Constants for archive content +const METADATA_JSON: &[u8] = br#"{ + "cedar_version": "4.4.0", + "policy_store": { + "id": "bench123456789", + "name": "Benchmark Policy Store", + "version": "1.0.0" + } + }"#; + +const SCHEMA_CEDARSCHEMA_BASIC: &[u8] = br#"namespace TestApp { + entity User; + entity Resource; + action "read" appliesTo { + principal: [User], + resource: [Resource] + }; +}"#; + +const SCHEMA_CEDARSCHEMA_WITH_ATTRS: &[u8] = br#"namespace TestApp { + entity User { + name: String, + email: String, + }; + entity Resource; + action "read" appliesTo { + principal: [User], + resource: [Resource] + }; +}"#; + +const POLICY_STORE_JSON: &str = + r#"{"cedar_version":"4.4.0","policy_store":{"id":"bench","name":"Bench","version":"1.0.0"}}"#; + +/// Helper function to start a policy store archive with common bootstrap setup. +/// Creates the buffer, cursor, ZipWriter, sets up FileOptions, and writes +/// metadata.json and schema.cedarschema. Returns the ZipWriter and FileOptions +/// for the caller to append additional content. +fn start_policy_store_archive(metadata: &[u8], schema: &[u8]) -> ZipWriter>> { + let buffer = Vec::new(); + let cursor = Cursor::new(buffer); + let mut zip = ZipWriter::new(cursor); + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + + // metadata.json + zip.start_file("metadata.json", options.clone()).unwrap(); + zip.write_all(metadata).unwrap(); + + // schema.cedarschema + zip.start_file("schema.cedarschema", options.clone()) + .unwrap(); + zip.write_all(schema).unwrap(); + + zip +} + +/// Helper function to parse an archive and read all files. +/// This simulates the loading process for benchmarking. +fn parse_archive(archive: &[u8]) -> u64 { + let cursor = Cursor::new(bb(archive)); + let mut zip = zip::ZipArchive::new(cursor).unwrap(); + + // Read all files to simulate loading + let mut total_size = 0; + for i in 0..zip.len() { + let mut file = zip.by_index(i).unwrap(); + let bytes_read = std::io::copy(&mut file, &mut std::io::sink()).unwrap(); + total_size += bytes_read; + } + total_size +} + +/// Create a minimal valid policy store archive for benchmarking. +fn create_minimal_archive() -> Vec { + create_archive_with_policies(1) +} + +/// Create a policy store archive with the specified number of policies. +fn create_archive_with_policies(policy_count: usize) -> Vec { + let mut zip = start_policy_store_archive(METADATA_JSON, SCHEMA_CEDARSCHEMA_BASIC); + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + + // policies + for i in 0..policy_count { + let filename = format!("policies/policy{}.cedar", i); + zip.start_file(&filename, options.clone()).unwrap(); + let policy = format!( + r#"@id("policy{}") +permit( + principal == TestApp::User::"user{}", + action == TestApp::Action::"read", + resource == TestApp::Resource::"res{}" +);"#, + i, i, i + ); + zip.write_all(policy.as_bytes()).unwrap(); + } + + zip.finish().unwrap().into_inner() +} + +/// Create a policy store archive with the specified number of entities. +fn create_archive_with_entities(entity_count: usize) -> Vec { + let mut zip = start_policy_store_archive(METADATA_JSON, SCHEMA_CEDARSCHEMA_WITH_ATTRS); + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + + // One policy + zip.start_file("policies/allow.cedar", options.clone()) + .unwrap(); + zip.write_all(br#"@id("allow") permit(principal, action, resource);"#) + .unwrap(); + + // Entities in batches + let batch_size = 500; + let batches = (entity_count + batch_size - 1) / batch_size; + + for batch in 0..batches { + let start = batch * batch_size; + let end = ((batch + 1) * batch_size).min(entity_count); + + let mut entities = Vec::new(); + for i in start..end { + // Format string split across lines for readability (under 100 chars per line) + entities.push(format!( + concat!( + r#"{{"uid":{{"type":"TestApp::User","id":"user{}"}},"#, + r#""attrs":{{"name":"User {}","email":"user{}@example.com"}},"#, + r#""parents":[]}}"# + ), + i, i, i + )); + } + + let filename = format!("entities/users_batch{}.json", batch); + zip.start_file(&filename, options.clone()).unwrap(); + let content = format!("[{}]", entities.join(",")); + zip.write_all(content.as_bytes()).unwrap(); + } + + zip.finish().unwrap().into_inner() +} + +/// Benchmark loading a minimal policy store. +/// +/// Note: This benchmark measures archive decompression and parsing overhead, +/// not the full Cedarling initialization which involves more complex setup. +fn bench_archive_parsing(c: &mut Criterion) { + let archive = create_minimal_archive(); + + c.bench_function("archive_parse_minimal", |b| { + b.iter_batched( + || archive.clone(), + |archive_bytes| { + // Measure ZIP parsing overhead (clone is done in setup, not measured) + let cursor = Cursor::new(bb(archive_bytes)); + let archive = zip::ZipArchive::new(cursor).unwrap(); + bb(archive.len()) + }, + BatchSize::PerIteration, + ) + }); +} + +/// Benchmark archive creation with varying policy counts. +fn bench_archive_creation(c: &mut Criterion) { + let mut group = c.benchmark_group("archive_creation"); + + // Keep policy counts low to stay under 1ms threshold + for policy_count in [5, 10].iter() { + group.bench_with_input( + BenchmarkId::new("policies", policy_count), + policy_count, + |b, &count| b.iter(|| bb(create_archive_with_policies(count))), + ); + } + + group.finish(); +} + +/// Benchmark archive parsing with varying policy counts. +fn bench_archive_parsing_policies(c: &mut Criterion) { + let mut group = c.benchmark_group("archive_parse_policies"); + + // Keep policy counts low to stay under 1ms threshold + for policy_count in [10, 50, 100].iter() { + let archive = create_archive_with_policies(*policy_count); + + group.bench_with_input( + BenchmarkId::new("parse", policy_count), + &archive, + |b, archive| { + b.iter(|| { + let total_size = parse_archive(archive); + bb(total_size) + }) + }, + ); + } + + group.finish(); +} + +/// Benchmark archive parsing with varying entity counts. +fn bench_archive_parsing_entities(c: &mut Criterion) { + let mut group = c.benchmark_group("archive_parse_entities"); + + for entity_count in [100, 500, 1000, 5000].iter() { + let archive = create_archive_with_entities(*entity_count); + + group.bench_with_input( + BenchmarkId::new("parse", entity_count), + &archive, + |b, archive| { + b.iter(|| { + let total_size = parse_archive(archive); + bb(total_size) + }) + }, + ); + } + + group.finish(); +} + +/// Benchmark directory creation (native only). +#[cfg(not(target_arch = "wasm32"))] +fn bench_directory_creation(c: &mut Criterion) { + use std::fs; + + let mut group = c.benchmark_group("directory_creation"); + + // Keep policy counts low to stay under 1ms threshold + for policy_count in [5, 10].iter() { + group.bench_with_input( + BenchmarkId::new("policies", policy_count), + policy_count, + |b, &count| { + b.iter(|| { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path(); + + // Create metadata.json + fs::write(dir.join("metadata.json"), POLICY_STORE_JSON).unwrap(); + + // Create schema + fs::write( + dir.join("schema.cedarschema"), + "namespace TestApp { entity User; entity Resource; }", + ) + .unwrap(); + + // Create policies directory + fs::create_dir(dir.join("policies")).unwrap(); + + for i in 0..count { + let policy = + format!(r#"@id("policy{}") permit(principal, action, resource);"#, i); + fs::write(dir.join(format!("policies/policy{}.cedar", i)), policy).unwrap(); + } + + bb(temp_dir) + }) + }, + ); + } + + group.finish(); +} + +criterion_group!( + benches, + bench_archive_parsing, + bench_archive_creation, + bench_archive_parsing_policies, + bench_archive_parsing_entities, +); + +#[cfg(not(target_arch = "wasm32"))] +criterion_group!(directory_benches, bench_directory_creation,); + +#[cfg(not(target_arch = "wasm32"))] +criterion_main!(benches, directory_benches); + +#[cfg(target_arch = "wasm32")] +criterion_main!(benches); diff --git a/jans-cedarling/cedarling/src/authz/trust_mode.rs b/jans-cedarling/cedarling/src/authz/trust_mode.rs index 659c259b91a..f93cad517fc 100644 --- a/jans-cedarling/cedarling/src/authz/trust_mode.rs +++ b/jans-cedarling/cedarling/src/authz/trust_mode.rs @@ -114,8 +114,7 @@ mod test { serde_json::from_value(json!({"client_id": "some-id-123"})) .expect("valid token claims"), None, - ) - .into(); + ); let id_token = Token::new( "id_token", serde_json::from_value(json!({"aud": ["some-id-123"]})).expect("valid token claims"), @@ -124,8 +123,7 @@ mod test { let tokens = HashMap::from([ ("access_token".to_string(), Arc::new(access_token)), ("id_token".to_string(), Arc::new(id_token)), - ]) - .into(); + ]); validate_id_tkn_trust_mode(&tokens).expect("should not error"); } diff --git a/jans-cedarling/cedarling/src/bootstrap_config/decode.rs b/jans-cedarling/cedarling/src/bootstrap_config/decode.rs index 63e37ce7e8f..17e716d73ac 100644 --- a/jans-cedarling/cedarling/src/bootstrap_config/decode.rs +++ b/jans-cedarling/cedarling/src/bootstrap_config/decode.rs @@ -86,24 +86,36 @@ impl BootstrapConfig { (Some(policy_store), None, None) => PolicyStoreConfig { source: PolicyStoreSource::Json(policy_store), }, - // Case: get the policy store from the lock server - (None, Some(policy_store_uri), None) => PolicyStoreConfig { - source: PolicyStoreSource::LockServer(policy_store_uri), + // Case: get the policy store from a URI (auto-detect .cjar archives) + (None, Some(policy_store_uri), None) => { + let source = if policy_store_uri.to_lowercase().ends_with(".cjar") { + PolicyStoreSource::CjarUrl(policy_store_uri) + } else { + PolicyStoreSource::LockServer(policy_store_uri) + }; + PolicyStoreConfig { source } }, - // Case: get the policy store from a local JSON file + // Case: get the policy store from a local file or directory (None, None, Some(raw_path)) => { let path = Path::new(&raw_path); - let file_ext = Path::new(&path) - .extension() - .and_then(|ext| ext.to_str()) - .map(|x| x.to_lowercase()); - let source = match file_ext.as_deref() { - Some("json") => PolicyStoreSource::FileJson(path.into()), - Some("yaml") | Some("yml") => PolicyStoreSource::FileYaml(path.into()), - _ => Err( - BootstrapConfigLoadingError::UnsupportedPolicyStoreFileFormat(raw_path), - )?, + // Check if it's a directory first + let source = if path.is_dir() { + PolicyStoreSource::Directory(path.into()) + } else { + let file_ext = path + .extension() + .and_then(|ext| ext.to_str()) + .map(|x| x.to_lowercase()); + + match file_ext.as_deref() { + Some("json") => PolicyStoreSource::FileJson(path.into()), + Some("yaml") | Some("yml") => PolicyStoreSource::FileYaml(path.into()), + Some("cjar") => PolicyStoreSource::CjarFile(path.into()), + _ => Err( + BootstrapConfigLoadingError::UnsupportedPolicyStoreFileFormat(raw_path), + )?, + } }; PolicyStoreConfig { source } }, diff --git a/jans-cedarling/cedarling/src/bootstrap_config/mod.rs b/jans-cedarling/cedarling/src/bootstrap_config/mod.rs index 183b9ebe12f..6ca2ccb8271 100644 --- a/jans-cedarling/cedarling/src/bootstrap_config/mod.rs +++ b/jans-cedarling/cedarling/src/bootstrap_config/mod.rs @@ -227,6 +227,12 @@ pub enum BootstrapConfigLoadingError { /// Error returned when the lock server configuration URI is invalid. #[error("Invalid lock server configuration URI: {0}")] InvalidLockServerConfigUri(url::ParseError), + + /// Error returned when cjar_url is missing or empty. + #[error( + "cjar_url is missing or empty. A valid URL is required for CjarUrl policy store source." + )] + MissingCjarUrl, } impl From for BootstrapConfigLoadingError { diff --git a/jans-cedarling/cedarling/src/bootstrap_config/policy_store_config.rs b/jans-cedarling/cedarling/src/bootstrap_config/policy_store_config.rs index ae917c2b419..5f0608be730 100644 --- a/jans-cedarling/cedarling/src/bootstrap_config/policy_store_config.rs +++ b/jans-cedarling/cedarling/src/bootstrap_config/policy_store_config.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use crate::bootstrap_config::BootstrapConfigLoadingError; + /// `PolicyStoreConfig` - Configuration for the policy store. /// /// Defines where the policy will be retrieved from. @@ -47,6 +49,32 @@ pub enum PolicyStoreSource { /// Read policy from a YAML File. FileYaml(PathBuf), + + /// Read policy from a Cedar Archive (.cjar) file. + /// + /// The path points to a `.cjar` archive containing the policy store + /// in the new directory structure format. + CjarFile(PathBuf), + + /// Read policy from a Cedar Archive (.cjar) fetched from a URL. + /// + /// The string contains a URL where the `.cjar` archive can be downloaded. + CjarUrl(String), + + /// Read policy from a directory structure. + /// + /// The path points to a directory containing the policy store + /// in the new directory structure format (with manifest.json, policies/, etc.). + Directory(PathBuf), + + /// Read policy from Cedar Archive bytes directly. + /// + /// The bytes contain a `.cjar` archive (ZIP format) with the policy store. + /// This is particularly useful for: + /// - WASM environments with custom fetch logic + /// - Embedding archives in applications + /// - Loading from non-standard sources (databases, S3, etc.) + ArchiveBytes(Vec), } /// Raw policy store source @@ -61,19 +89,46 @@ pub enum PolicyStoreSourceRaw { FileJson(String), /// File YAML FileYaml(String), + /// Cedar Archive file (.cjar) + CjarFile(String), + /// Cedar Archive URL (.cjar) + CjarUrl(String), + /// Directory structure + Directory(String), } -impl From for PolicyStoreConfig { - fn from(raw: PolicyStoreConfigRaw) -> Self { - Self { - source: match raw.source.as_str() { - "json" => PolicyStoreSource::Json(raw.path.unwrap_or_default()), - "yaml" => PolicyStoreSource::Yaml(raw.path.unwrap_or_default()), - "lock_server" => PolicyStoreSource::LockServer(raw.path.unwrap_or_default()), - "file_json" => PolicyStoreSource::FileJson(raw.path.unwrap_or_default().into()), - "file_yaml" => PolicyStoreSource::FileYaml(raw.path.unwrap_or_default().into()), - _ => PolicyStoreSource::FileYaml("policy-store.yaml".into()), +impl TryFrom for PolicyStoreConfig { + type Error = BootstrapConfigLoadingError; + + fn try_from(raw: PolicyStoreConfigRaw) -> Result { + let source = match raw.source.as_str() { + "json" => PolicyStoreSource::Json(raw.path.unwrap_or_default()), + "yaml" => PolicyStoreSource::Yaml(raw.path.unwrap_or_default()), + + "lock_server" => PolicyStoreSource::LockServer(raw.path.unwrap_or_default()), + "file_json" => PolicyStoreSource::FileJson(raw.path.unwrap_or_default().into()), + "file_yaml" => PolicyStoreSource::FileYaml(raw.path.unwrap_or_default().into()), + "cjar_file" => PolicyStoreSource::CjarFile( + raw.path + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| "policy-store.cjar".to_string()) + .into(), + ), + "cjar_url" => { + let url = raw.path.filter(|p| !p.is_empty()).unwrap_or_default(); + if url.is_empty() { + return Err(BootstrapConfigLoadingError::MissingCjarUrl); + } + PolicyStoreSource::CjarUrl(url) }, - } + "directory" => PolicyStoreSource::Directory( + raw.path + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| "policy-store".to_string()) + .into(), + ), + _ => PolicyStoreSource::FileYaml("policy-store.yaml".into()), + }; + Ok(Self { source }) } } diff --git a/jans-cedarling/cedarling/src/common/app_types.rs b/jans-cedarling/cedarling/src/common/app_types.rs index d6b3e6c683a..8099e8a0cd3 100644 --- a/jans-cedarling/cedarling/src/common/app_types.rs +++ b/jans-cedarling/cedarling/src/common/app_types.rs @@ -13,10 +13,10 @@ use uuid7::{Uuid, uuid4}; /// represents a unique ID for application /// generated one on startup #[derive(Debug, Clone, Copy, Serialize, PartialEq, Display)] -pub(crate) struct PdpID(pub Uuid); +pub struct PdpID(pub Uuid); impl PdpID { - pub fn new() -> Self { + pub(crate) fn new() -> Self { // we use uuid v4 because it is generated based on random numbers. PdpID(uuid4()) } diff --git a/jans-cedarling/cedarling/src/common/policy_store.rs b/jans-cedarling/cedarling/src/common/policy_store.rs index 80bd9f4bf63..ac49d701015 100644 --- a/jans-cedarling/cedarling/src/common/policy_store.rs +++ b/jans-cedarling/cedarling/src/common/policy_store.rs @@ -3,9 +3,14 @@ // // Copyright (c) 2024, Gluu, Inc. +#[cfg(test)] +mod archive_security_tests; mod claim_mapping; +pub(crate) mod log_entry; #[cfg(test)] mod test; +#[cfg(test)] +pub mod test_utils; mod token_entity_metadata; use crate::common::{ @@ -14,6 +19,20 @@ use crate::common::{ issuer_utils::normalize_issuer, }; +pub(crate) mod archive_handler; +pub(crate) mod entity_parser; +pub mod errors; +pub(crate) mod issuer_parser; +pub(crate) mod loader; +pub mod manager; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) mod manifest_validator; +pub mod metadata; +pub(crate) mod policy_parser; +pub(crate) mod schema_parser; +pub(crate) mod validator; +pub(crate) mod vfs_adapter; + use super::{PartitionResult, cedar_schema::CedarSchema}; use cedar_policy::{Policy, PolicyId}; use semver::Version; @@ -24,6 +43,9 @@ use url::Url; pub(crate) use claim_mapping::ClaimMappings; pub use token_entity_metadata::TokenEntityMetadata; +// Re-export types used by init/policy_store.rs and external consumers +pub use manager::{ConversionError, PolicyStoreManager}; +pub use metadata::PolicyStoreMetadata; /// This is the top-level struct in compliance with the Agama Lab Policy Designer format. #[derive(Debug, Clone)] #[cfg_attr(test, derive(PartialEq))] @@ -170,7 +192,10 @@ pub struct TrustedIssuersValidationError { oidc_url: String, } -/// Wrapper around [`PolicyStore`] to have access to it and ID of policy store +/// Wrapper around [`PolicyStore`] to have access to it and ID of policy store. +/// +/// When loaded from the new directory/archive format, includes optional metadata +/// containing version, description, and other policy store information. #[derive(Clone, derive_more::Deref)] pub struct PolicyStoreWithID { /// ID of policy store @@ -178,6 +203,9 @@ pub struct PolicyStoreWithID { /// Policy store value #[deref] pub store: PolicyStore, + /// Optional metadata from new format policy stores. + /// Contains cedar_version, policy_store info (name, version, description, etc.) + pub metadata: Option, } /// Represents a trusted issuer that can provide JWTs. @@ -385,7 +413,10 @@ enum MaybeEncoded { /// Represents a raw policy entry from the `PolicyStore`. /// /// This is a helper struct used internally for parsing base64-encoded policies. -#[derive(Debug, Clone, PartialEq, Deserialize)] +// TODO: We only use the `description` field at runtime. The raw `policy_content` +// is not needed once policies are compiled into a `PolicySet`. Refactor +// to remove `RawPolicy` (and stored raw content) and keep only descriptions. +#[derive(Debug, Clone, PartialEq, serde::Deserialize)] struct RawPolicy { /// Base64-encoded content of the policy. pub policy_content: MaybeEncoded, @@ -433,6 +464,45 @@ impl PartialEq for PoliciesContainer { } impl PoliciesContainer { + /// Create a new `PoliciesContainer` from a policy set and description map. + /// + /// This constructor is used by the policy store manager when converting + /// from the new directory/archive format to the legacy format. + /// + /// # Arguments + /// + /// * `policy_set` - The compiled Cedar policy set + /// * `descriptions` - Map of policy ID to description (typically filename) + pub fn new(policy_set: cedar_policy::PolicySet, descriptions: HashMap) -> Self { + let raw_policy_info = descriptions + .into_iter() + .map(|(id, desc)| { + ( + id, + RawPolicy { + policy_content: MaybeEncoded::Plain(String::new()), + description: desc, + }, + ) + }) + .collect(); + + Self { + policy_set, + raw_policy_info, + } + } + + /// Create an empty `PoliciesContainer` with the given policy set. + /// + /// Used when there are no policy descriptions available. + pub fn new_empty(policy_set: cedar_policy::PolicySet) -> Self { + Self { + policy_set, + raw_policy_info: HashMap::new(), + } + } + /// Get [`cedar_policy::PolicySet`] pub fn get_set(&self) -> &cedar_policy::PolicySet { &self.policy_set diff --git a/jans-cedarling/cedarling/src/common/policy_store/archive_handler.rs b/jans-cedarling/cedarling/src/common/policy_store/archive_handler.rs new file mode 100644 index 00000000000..86ebe9ac3c8 --- /dev/null +++ b/jans-cedarling/cedarling/src/common/policy_store/archive_handler.rs @@ -0,0 +1,610 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Archive VFS implementation for .cjar policy store archives. +//! +//! This module provides a VFS implementation backed by ZIP archives, enabling +//! policy stores to be distributed as single `.cjar` files. The implementation: +//! +//! - **Fully WASM-compatible** - `from_buffer()` works in both native and WASM +//! - Reads files on-demand from the archive (no extraction needed) +//! - Validates archive format and structure during construction +//! - Prevents path traversal attacks +//! - Provides full VfsFileSystem trait implementation +//! +//! # WASM Support +//! +//! Archives are **fully supported in WASM**: +//! - Use `ArchiveVfs::from_buffer()` with bytes you fetch (works now) +//! - Use `ArchiveSource::Url` with `load_policy_store()` (once URL fetching is implemented) +//! - Only `from_file()` is native-only (requires file system access) +use super::errors::ArchiveError; +use super::vfs_adapter::{DirEntry, VfsFileSystem}; +use std::io::{Cursor, Read, Seek}; +#[cfg(not(target_arch = "wasm32"))] +use std::path::Path; +use std::sync::Mutex; +use zip::ZipArchive; + +/// VFS implementation backed by a ZIP archive. +/// +/// This implementation reads files on-demand from a ZIP archive without extraction, +/// making it efficient and WASM-compatible. The archive is validated during construction +/// to ensure it's a valid .cjar file with no path traversal attempts. +/// +/// # Thread Safety +/// +/// This type is `Send + Sync` despite using `Mutex` because the `ZipArchive` is protected +/// by a mutex. Concurrent access is prevented by the Mutex locking mechanism. +/// +/// # Generic Type Parameter +/// +/// The generic type `T` must implement `Read + Seek` and represents the underlying +/// reader for the ZIP archive. Common types: +/// - `Cursor>` - For in-memory archives (WASM-compatible) +/// - `std::fs::File` - For file-based archives (native only) +#[derive(Debug)] +pub struct ArchiveVfs { + /// The ZIP archive reader (wrapped in Mutex for thread safety) + archive: Mutex>, +} + +impl ArchiveVfs +where + T: Read + Seek, +{ + /// Create an ArchiveVfs from a reader. + /// + /// This method: + /// 1. Validates the reader contains a valid ZIP archive + /// 2. Checks for path traversal attempts + /// 3. Validates archive structure + /// + /// # Errors + /// + /// Returns `ArchiveError` if: + /// - Reader does not contain a valid ZIP archive + /// - Archive contains path traversal attempts + /// - Archive is corrupted + pub fn from_reader(reader: T) -> Result { + let mut archive = ZipArchive::new(reader).map_err(|e| ArchiveError::InvalidZipFormat { + details: e.to_string(), + })?; + + // Validate all file names for security using zip crate's enclosed_name() + // which properly validates and normalizes paths, preventing path traversal + for i in 0..archive.len() { + let file = archive + .by_index(i) + .map_err(|e| ArchiveError::CorruptedEntry { + index: i, + details: e.to_string(), + })?; + + // Use enclosed_name() to validate and normalize the path + // This properly handles path traversal, backslashes, and absolute paths + let normalized = file.enclosed_name(); + if let Some(normalized_path) = normalized { + // Additional check: ensure normalized path doesn't contain .. sequences + // enclosed_name() normalizes but may not reject all .. patterns + let path_str = normalized_path.to_string_lossy(); + if path_str.contains("..") { + return Err(ArchiveError::PathTraversal { + path: file.name().to_string(), + }); + } + } else { + return Err(ArchiveError::PathTraversal { + path: file.name().to_string(), + }); + } + } + + Ok(Self { + archive: Mutex::new(archive), + }) + } +} + +impl ArchiveVfs { + /// Create an ArchiveVfs from a file path (native only). + /// + /// This method: + /// 1. Validates the file has .cjar extension + /// 2. Opens the file + /// 3. Validates it's a valid ZIP archive + /// 4. Checks for path traversal attempts + /// + /// # Errors + /// + /// Returns `ArchiveError` if: + /// - File extension is not .cjar + /// - File cannot be read + /// - Archive is not a valid ZIP + /// - Archive contains path traversal attempts + /// - Archive is corrupted + #[cfg(not(target_arch = "wasm32"))] + pub fn from_file>(path: P) -> Result { + let path = path.as_ref(); + + // Validate extension + if path.extension().and_then(|s| s.to_str()) != Some("cjar") { + return Err(ArchiveError::InvalidExtension { + expected: "cjar".to_string(), + found: path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("(none)") + .to_string(), + }); + } + + let file = std::fs::File::open(path).map_err(|e| ArchiveError::CannotReadFile { + path: path.display().to_string(), + source: e, + })?; + + Self::from_reader(file) + } +} + +impl ArchiveVfs>> { + /// Create an ArchiveVfs from bytes (works in WASM and native). + /// + /// This method: + /// 1. Validates the bytes form a valid ZIP archive + /// 2. Checks for path traversal attempts + /// 3. Validates archive structure + /// + /// # Errors + /// + /// Returns `ArchiveError` if: + /// - Bytes are not a valid ZIP archive + /// - Archive contains path traversal attempts + /// - Archive is corrupted + pub fn from_buffer(buffer: Vec) -> Result { + let cursor = Cursor::new(buffer); + Self::from_reader(cursor) + } +} + +impl ArchiveVfs +where + T: Read + Seek, +{ + /// Normalize a path for archive lookup. + /// + /// Handles: + /// - Converting absolute paths to relative + /// - Removing leading slashes + /// - Removing leading "./" prefix + /// - Converting "." to "" + /// - Normalizing path separators + fn normalize_path(&self, path: &str) -> String { + let path = path.trim_start_matches('/'); + let path = path.strip_prefix("./").unwrap_or(path); + if path == "." || path.is_empty() { + String::new() + } else { + path.to_string() + } + } + + /// Check if a path exists in the archive (file or directory). + fn path_exists(&self, path: &str) -> Result { + let normalized = self.normalize_path(path); + + let mut archive = self + .archive + .lock() + .map_err(|e| std::io::Error::other(format!("archive mutex poisoned: {}", e)))?; + + // Check if it's a file + if archive.by_name(&normalized).is_ok() { + return Ok(true); + } + + // Check if it's a directory by looking for entries that start with this prefix + let dir_prefix = if normalized.is_empty() { + String::new() + } else { + format!("{}/", normalized) + }; + + for i in 0..archive.len() { + if let Ok(file) = archive.by_index(i) { + let file_name = file.name(); + if file_name == normalized || file_name.starts_with(&dir_prefix) { + return Ok(true); + } + } + } + + Ok(false) + } + + /// Check if a path is a directory in the archive. + fn is_directory(&self, path: &str) -> Result { + let normalized = self.normalize_path(path); + let mut archive = self + .archive + .lock() + .map_err(|e| std::io::Error::other(format!("archive mutex poisoned: {}", e)))?; + Ok(Self::is_directory_locked(&mut archive, &normalized)) + } + + /// Check if a path is a directory (with already-locked archive). + /// This is a helper to avoid deadlocks when called from methods that already hold the lock. + fn is_directory_locked(archive: &mut ZipArchive, normalized: &str) -> bool { + // Root is always a directory + if normalized.is_empty() { + return true; + } + + // Check if there's an explicit directory entry + let dir_path_with_slash = format!("{}/", normalized); + if let Ok(file) = archive.by_name(&dir_path_with_slash) { + return file.is_dir(); + } + + // Check if any files have this as a prefix (implicit directory) + for i in 0..archive.len() { + if let Ok(file) = archive.by_index(i) { + let file_name = file.name(); + if file_name.starts_with(&format!("{}/", normalized)) { + return true; + } + } + } + + false + } +} + +impl VfsFileSystem for ArchiveVfs +where + T: Read + Seek + Send + Sync + 'static, +{ + fn read_file(&self, path: &str) -> Result, std::io::Error> { + let normalized = self.normalize_path(path); + + let mut archive = self + .archive + .lock() + .map_err(|e| std::io::Error::other(format!("archive mutex poisoned: {}", e)))?; + + let mut file = archive.by_name(&normalized).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("File not found in archive: {}: {}", path, e), + ) + })?; + + let mut contents = Vec::new(); + file.read_to_end(&mut contents)?; + + Ok(contents) + } + + fn exists(&self, path: &str) -> bool { + self.path_exists(path).unwrap_or(false) + } + + fn is_dir(&self, path: &str) -> bool { + self.is_directory(path).unwrap_or(false) + } + + fn is_file(&self, path: &str) -> bool { + let normalized = self.normalize_path(path); + let mut archive = match self.archive.lock() { + Ok(archive) => archive, + Err(_) => return false, // Return false if mutex is poisoned + }; + + if let Ok(file) = archive.by_name(&normalized) { + return file.is_file(); + } + + false + } + + fn read_dir(&self, path: &str) -> Result, std::io::Error> { + let normalized = self.normalize_path(path); + let prefix = if normalized.is_empty() { + String::new() + } else { + format!("{}/", normalized) + }; + + let mut archive = self + .archive + .lock() + .map_err(|e| std::io::Error::other(format!("archive mutex poisoned: {}", e)))?; + let mut seen = std::collections::HashSet::new(); + let mut entry_paths = Vec::new(); + + // First pass: collect all unique entry paths + for i in 0..archive.len() { + let file = archive.by_index(i).map_err(|e| { + std::io::Error::other(format!("Failed to read archive entry {}: {}", i, e)) + })?; + + let file_name = file.name(); + + // Check if this file is in the requested directory + if file_name.starts_with(&prefix) || (prefix.is_empty() && !file_name.contains('/')) { + let relative = if prefix.is_empty() { + file_name + } else { + &file_name[prefix.len()..] + }; + + // Get the immediate child name (first component) + let child_name = if let Some(slash_pos) = relative.find('/') { + &relative[..slash_pos] + } else { + relative + }; + + // Skip empty names and deduplicate + if child_name.is_empty() || !seen.insert(child_name.to_string()) { + continue; + } + + // Determine the full path for this entry + let entry_path = if prefix.is_empty() { + child_name.to_string() + } else { + format!("{}{}", prefix, child_name) + }; + + entry_paths.push((child_name.to_string(), entry_path)); + } + } + + // Second pass: check if each path is a directory + let mut entries = Vec::new(); + for (name, entry_path) in entry_paths { + let entry_path_normalized = self.normalize_path(&entry_path); + let is_directory = Self::is_directory_locked(&mut archive, &entry_path_normalized); + + entries.push(DirEntry { + name, + path: entry_path, + is_dir: is_directory, + }); + } + + Ok(entries) + } + + fn open_file(&self, path: &str) -> Result, std::io::Error> { + let bytes = self.read_file(path)?; + Ok(Box::new(Cursor::new(bytes))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use zip::CompressionMethod; + use zip::write::{ExtendedFileOptions, FileOptions}; + + /// Helper to create a test .cjar archive in memory + fn create_test_archive(files: Vec<(&str, &str)>) -> Vec { + let mut buffer = Vec::new(); + { + let cursor = Cursor::new(&mut buffer); + let mut zip = zip::ZipWriter::new(cursor); + + for (name, content) in files { + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + zip.start_file(name, options).unwrap(); + zip.write_all(content.as_bytes()).unwrap(); + } + + zip.finish().unwrap(); + } + buffer + } + + #[test] + fn test_from_buffer_valid_archive() { + let bytes = create_test_archive(vec![("metadata.json", "{}")]); + let _result = ArchiveVfs::from_buffer(bytes) + .expect("expect ArchiveVfs initialized correctly from buffer"); + } + + #[test] + fn test_from_buffer_invalid_zip() { + let bytes = b"This is not a ZIP file".to_vec(); + let result = ArchiveVfs::from_buffer(bytes); + let err = result.expect_err("Expected InvalidZipFormat error for non-ZIP data"); + assert!( + matches!(err, ArchiveError::InvalidZipFormat { .. }), + "Expected InvalidZipFormat error, got: {:?}", + err + ); + } + + #[test] + fn test_from_buffer_path_traversal() { + let bytes = create_test_archive(vec![("../../../etc/passwd", "malicious")]); + let result = ArchiveVfs::from_buffer(bytes); + let err = result.expect_err("Expected PathTraversal error for malicious path"); + assert!( + matches!(err, ArchiveError::PathTraversal { .. }), + "Expected PathTraversal error, got: {:?}", + err + ); + } + + #[test] + fn test_read_file_success() { + let bytes = create_test_archive(vec![ + ("metadata.json", r#"{"version":"1.0"}"#), + ("schema.cedarschema", "namespace Test;"), + ]); + let vfs = ArchiveVfs::from_buffer(bytes).unwrap(); + + let content = vfs.read_file("metadata.json").unwrap(); + assert_eq!(String::from_utf8(content).unwrap(), r#"{"version":"1.0"}"#); + + let content = vfs.read_file("schema.cedarschema").unwrap(); + assert_eq!(String::from_utf8(content).unwrap(), "namespace Test;"); + } + + #[test] + fn test_read_file_not_found() { + let bytes = create_test_archive(vec![("metadata.json", "{}")]); + let vfs = ArchiveVfs::from_buffer(bytes).unwrap(); + + let result = vfs.read_file("nonexistent.json"); + let err = result.expect_err("Expected error for nonexistent file"); + assert!( + matches!(err, std::io::Error { .. }), + "Expected IO error for file not found" + ); + } + + #[test] + fn test_exists() { + let bytes = create_test_archive(vec![ + ("metadata.json", "{}"), + ("policies/policy1.cedar", "permit();"), + ]); + let vfs = ArchiveVfs::from_buffer(bytes).unwrap(); + + assert!(vfs.exists("metadata.json")); + assert!(vfs.exists("policies/policy1.cedar")); + assert!(vfs.exists("policies")); // directory + assert!(!vfs.exists("nonexistent.json")); + } + + #[test] + fn test_is_file() { + let bytes = create_test_archive(vec![ + ("metadata.json", "{}"), + ("policies/policy1.cedar", "permit();"), + ]); + let vfs = ArchiveVfs::from_buffer(bytes).unwrap(); + + assert!(vfs.is_file("metadata.json")); + assert!(vfs.is_file("policies/policy1.cedar")); + assert!(!vfs.is_file("policies")); + assert!(!vfs.is_file("nonexistent.json")); + } + + #[test] + fn test_is_dir() { + let bytes = create_test_archive(vec![ + ("metadata.json", "{}"), + ("policies/policy1.cedar", "permit();"), + ("policies/policy2.cedar", "forbid();"), + ]); + let vfs = ArchiveVfs::from_buffer(bytes).unwrap(); + + assert!(vfs.is_dir(".")); + assert!(vfs.is_dir("policies")); + assert!(!vfs.is_dir("metadata.json")); + assert!(!vfs.is_dir("nonexistent")); + } + + #[test] + fn test_read_dir_root() { + let bytes = create_test_archive(vec![ + ("metadata.json", "{}"), + ("schema.cedarschema", "namespace Test;"), + ("policies/policy1.cedar", "permit();"), + ]); + let vfs = ArchiveVfs::from_buffer(bytes).unwrap(); + + let entries = vfs.read_dir(".").unwrap(); + assert_eq!(entries.len(), 3); + + let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect(); + assert!(names.contains(&"metadata.json")); + assert!(names.contains(&"schema.cedarschema")); + assert!(names.contains(&"policies")); + } + + #[test] + fn test_read_dir_subdirectory() { + let bytes = create_test_archive(vec![ + ("policies/policy1.cedar", "permit();"), + ("policies/policy2.cedar", "forbid();"), + ("policies/nested/policy3.cedar", "deny();"), + ]); + let vfs = ArchiveVfs::from_buffer(bytes).unwrap(); + + let entries = vfs.read_dir("policies").unwrap(); + assert_eq!(entries.len(), 3); + + let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect(); + assert!(names.contains(&"policy1.cedar")); + assert!(names.contains(&"policy2.cedar")); + assert!(names.contains(&"nested")); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn test_from_file_path_invalid_extension() { + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let archive_path = temp_dir.path().join("test.zip"); + + let bytes = create_test_archive(vec![("metadata.json", "{}")]); + std::fs::write(&archive_path, bytes).unwrap(); + + let result = ArchiveVfs::from_file(&archive_path); + assert!(matches!( + result.expect_err("should fail"), + ArchiveError::InvalidExtension { .. } + )); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn test_from_file_path_success() { + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let archive_path = temp_dir.path().join("test.cjar"); + + let bytes = create_test_archive(vec![("metadata.json", "{}")]); + std::fs::write(&archive_path, bytes).unwrap(); + + ArchiveVfs::from_file(&archive_path).expect("should load valid .cjar file"); + } + + #[test] + fn test_complex_directory_structure() { + let bytes = create_test_archive(vec![ + ("metadata.json", "{}"), + ("policies/allow/policy1.cedar", "permit();"), + ("policies/allow/policy2.cedar", "permit();"), + ("policies/deny/policy3.cedar", "forbid();"), + ("entities/users/admin.json", "{}"), + ("entities/users/regular.json", "{}"), + ("entities/groups/admins.json", "{}"), + ]); + let vfs = ArchiveVfs::from_buffer(bytes).unwrap(); + + // Test root + let root_entries = vfs.read_dir(".").unwrap(); + assert_eq!(root_entries.len(), 3); // metadata.json, policies, entities + + // Test policies directory + let policies_entries = vfs.read_dir("policies").unwrap(); + assert_eq!(policies_entries.len(), 2); // allow, deny + + // Test nested allow directory + let allow_entries = vfs.read_dir("policies/allow").unwrap(); + assert_eq!(allow_entries.len(), 2); // policy1.cedar, policy2.cedar + } +} diff --git a/jans-cedarling/cedarling/src/common/policy_store/archive_security_tests.rs b/jans-cedarling/cedarling/src/common/policy_store/archive_security_tests.rs new file mode 100644 index 00000000000..f91c3f5617d --- /dev/null +++ b/jans-cedarling/cedarling/src/common/policy_store/archive_security_tests.rs @@ -0,0 +1,727 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Security tests for policy store loading and validation. +//! +//! These tests verify protection against: +//! - Path traversal attacks in archives and directories +//! - Malicious archive handling +//! - Input validation for all file types +//! - Resource exhaustion (zip bombs, deeply nested structures) + +// Note: This module is cfg(test) via parent module declaration in policy_store.rs + +use std::io::{Cursor, Write}; + +use zip::write::{ExtendedFileOptions, FileOptions}; +use zip::{CompressionMethod, ZipWriter}; + +use super::archive_handler::ArchiveVfs; +use super::entity_parser::{EntityParser, ParsedEntity}; +use super::errors::{ArchiveError, PolicyStoreError, ValidationError}; +use super::issuer_parser::IssuerParser; +use super::loader::DefaultPolicyStoreLoader; +use super::test_utils::{ + PolicyStoreTestBuilder, create_corrupted_archive, create_deep_nested_archive, + create_path_traversal_archive, fixtures, +}; +use super::vfs_adapter::VfsFileSystem; + +// ============================================================================ +// Path Traversal Tests +// ============================================================================ + +mod path_traversal { + use super::*; + + #[test] + fn test_rejects_parent_directory_traversal_in_archive() { + let archive = create_path_traversal_archive(); + let result = ArchiveVfs::from_buffer(archive); + + let err = result.expect_err("Expected PathTraversal error"); + assert!( + matches!(err, ArchiveError::PathTraversal { .. }), + "Expected PathTraversal error, got: {:?}", + err + ); + } + + #[test] + fn test_rejects_absolute_path_in_archive() { + let buffer = Vec::new(); + let cursor = Cursor::new(buffer); + let mut zip = ZipWriter::new(cursor); + + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + zip.start_file("/etc/passwd", options).unwrap(); + zip.write_all(b"root:x:0:0").unwrap(); + + let archive = zip.finish().unwrap().into_inner(); + let result = ArchiveVfs::from_buffer(archive); + + let err = result.expect_err("archive with path traversal should be rejected"); + assert!( + matches!(err, ArchiveError::PathTraversal { .. }), + "expected PathTraversal error, got: {:?}", + err + ); + } + + #[test] + fn test_rejects_double_dot_sequences() { + let buffer = Vec::new(); + let cursor = Cursor::new(buffer); + let mut zip = ZipWriter::new(cursor); + + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + + // Try various double-dot sequences + let paths = [ + "foo/../../../etc/passwd", + "foo/bar/../../secret", + "policies/..%2F..%2Fetc/passwd", // URL encoded + ]; + + for path in paths { + let result = zip.start_file(path, options.clone()); + if result.is_ok() { + zip.write_all(b"content").unwrap(); + } + } + + let archive = zip.finish().unwrap().into_inner(); + let result = ArchiveVfs::from_buffer(archive); + + // Should reject due to path traversal + let err = result.expect_err("Expected PathTraversal error for double-dot sequences"); + assert!( + matches!(err, ArchiveError::PathTraversal { .. }), + "Expected PathTraversal error, got: {:?}", + err + ); + } + + #[test] + fn test_rejects_windows_path_separators() { + let buffer = Vec::new(); + let cursor = Cursor::new(buffer); + let mut zip = ZipWriter::new(cursor); + + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + + // Windows-style path traversal + zip.start_file("foo\\..\\..\\etc\\passwd", options).unwrap(); + zip.write_all(b"content").unwrap(); + + let archive = zip.finish().unwrap().into_inner(); + let result = ArchiveVfs::from_buffer(archive); + + // Should reject archives containing Windows-style path traversal + let err = result.expect_err("expected PathTraversal error for Windows path separators"); + assert!( + matches!(err, ArchiveError::PathTraversal { .. }), + "Expected PathTraversal error for Windows path separators, got: {:?}", + err + ); + } +} + +// ============================================================================ +// Malicious Archive Tests +// ============================================================================ + +mod malicious_archives { + use super::*; + + #[test] + fn test_rejects_corrupted_zip() { + let archive = create_corrupted_archive(); + let result = ArchiveVfs::from_buffer(archive); + + let err = result.expect_err("Expected InvalidZipFormat error"); + assert!( + matches!(err, ArchiveError::InvalidZipFormat { .. }), + "Expected InvalidZipFormat error, got: {:?}", + err + ); + } + + #[test] + fn test_rejects_non_zip_file() { + let not_a_zip = b"This is definitely not a ZIP file".to_vec(); + let result = ArchiveVfs::from_buffer(not_a_zip); + + let err = result.expect_err("Expected InvalidZipFormat error"); + assert!( + matches!(err, ArchiveError::InvalidZipFormat { .. }), + "Expected InvalidZipFormat error, got: {:?}", + err + ); + } + + #[test] + fn test_rejects_empty_file() { + let empty: Vec = Vec::new(); + let result = ArchiveVfs::from_buffer(empty); + let err = result.expect_err("empty buffer should not be a valid archive"); + assert!( + matches!(err, ArchiveError::InvalidZipFormat { .. }), + "Expected InvalidZipFormat error for empty buffer, got: {:?}", + err + ); + } + + #[test] + fn test_handles_empty_zip() { + let buffer = Vec::new(); + let cursor = Cursor::new(buffer); + let zip = ZipWriter::new(cursor); + let archive = zip.finish().unwrap().into_inner(); + + // Empty ZIP should be valid but have no files + let result = ArchiveVfs::from_buffer(archive); + let vfs = result.expect("Empty ZIP archive should be accepted by ArchiveVfs"); + assert!(!vfs.exists("metadata.json")); + } + + #[test] + fn test_deeply_nested_paths() { + let archive = create_deep_nested_archive(100); + let vfs = ArchiveVfs::from_buffer(archive) + .expect("ArchiveVfs should handle deeply nested paths without error"); + + // Verify VFS is usable for a deeply nested archive + vfs.read_dir(".") + .expect("Deeply nested archive paths should be readable"); + + // Verify that the deeply nested file can be read and contains correct data + let nested_path = (0..100).map(|_| "dir").collect::>().join("/") + "/file.txt"; + let content = vfs + .read_file(&nested_path) + .expect("Should be able to read file at deeply nested path"); + let content_str = String::from_utf8(content).expect("File content should be valid UTF-8"); + assert_eq!( + content_str, "deep content", + "File content should match expected value" + ); + } + + #[test] + fn test_handles_large_file_name() { + let buffer = Vec::new(); + let cursor = Cursor::new(buffer); + let mut zip = ZipWriter::new(cursor); + + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + + // Very long filename + let long_name = "a".repeat(1000) + ".json"; + zip.start_file(&long_name, options).unwrap(); + zip.write_all(b"{}").unwrap(); + + let archive = zip.finish().unwrap().into_inner(); + let vfs = ArchiveVfs::from_buffer(archive) + .expect("ArchiveVfs should handle archives with very long filenames"); + + // If accepted, verify VFS is functional + vfs.read_dir(".") + .expect("VFS created from archive with long filename should be readable"); + } +} + +// ============================================================================ +// Input Validation Tests +// ============================================================================ + +mod input_validation { + use super::*; + + #[test] + fn test_rejects_invalid_json_in_metadata() { + let builder = fixtures::invalid_metadata_json(); + let archive = builder.build_archive().unwrap(); + + let vfs = ArchiveVfs::from_buffer(archive).unwrap(); + let loader = DefaultPolicyStoreLoader::new(vfs); + let result = loader.load_directory("."); + + let err = result.expect_err("Expected error for invalid metadata JSON"); + assert!( + matches!( + &err, + PolicyStoreError::Validation(ValidationError::MetadataJsonParseFailed { .. }) + ), + "Expected MetadataJsonParseFailed validation error for metadata, got: {:?}", + err + ); + } + + #[test] + fn test_rejects_invalid_cedar_syntax() { + let builder = fixtures::invalid_policy_syntax(); + let archive = builder.build_archive().unwrap(); + + let vfs = ArchiveVfs::from_buffer(archive).unwrap(); + let loader = DefaultPolicyStoreLoader::new(vfs); + let result = loader.load_directory("."); + + let err = result.expect_err("Expected error for invalid Cedar syntax"); + assert!( + matches!( + &err, + PolicyStoreError::Validation(ValidationError::InvalidPolicyStoreId { .. }) + ), + "Expected InvalidPolicyStoreId validation error for invalid Cedar syntax fixture, got: {:?}", + err + ); + } + + #[test] + fn test_rejects_invalid_entity_json() { + let builder = fixtures::minimal_valid().with_entity("invalid", "{ not valid json }"); + + let archive = builder.build_archive().unwrap(); + let vfs = ArchiveVfs::from_buffer(archive).unwrap(); + let loader = DefaultPolicyStoreLoader::new(vfs); + let loaded = loader.load_directory(".").expect("should load directory"); + + // Parse entities to trigger validation + for entity_file in &loaded.entities { + let result = + EntityParser::parse_entities(&entity_file.content, &entity_file.name, None); + let err = result.expect_err("expected JSON parsing error for invalid entity JSON"); + assert!( + matches!(&err, PolicyStoreError::JsonParsing { .. }), + "Expected JSON parsing error for invalid entity JSON, got: {:?}", + err + ); + return; // Found the error, test passes + } + panic!("Expected to find invalid entity JSON but none found"); + } + + #[test] + fn test_rejects_invalid_trusted_issuer() { + let builder = fixtures::invalid_trusted_issuer(); + let archive = builder.build_archive().unwrap(); + + let vfs = ArchiveVfs::from_buffer(archive).unwrap(); + let loader = DefaultPolicyStoreLoader::new(vfs); + let loaded = loader.load_directory(".").expect("should load directory"); + + // Parse issuers to trigger validation + for issuer_file in &loaded.trusted_issuers { + let result = IssuerParser::parse_issuer(&issuer_file.content, &issuer_file.name); + let err = result.expect_err("expected TrustedIssuerError for invalid trusted issuer"); + assert!( + matches!(&err, PolicyStoreError::TrustedIssuerError { .. }), + "Expected TrustedIssuerError for invalid trusted issuer, got: {:?}", + err + ); + return; // Found the error, test passes + } + panic!("Expected to find invalid trusted issuer but none found"); + } + + #[test] + fn test_handles_duplicate_entity_uids_gracefully() { + let builder = fixtures::duplicate_entity_uids(); + let archive = builder.build_archive().unwrap(); + + let vfs = ArchiveVfs::from_buffer(archive).unwrap(); + let loader = DefaultPolicyStoreLoader::new(vfs); + let loaded = loader.load_directory(".").expect("should load directory"); + + // Parse all entities and detect duplicates + let mut all_parsed_entities: Vec = Vec::new(); + for entity_file in &loaded.entities { + let parsed = + EntityParser::parse_entities(&entity_file.content, &entity_file.name, None) + .expect("should parse entities"); + all_parsed_entities.extend(parsed); + } + + // Count entities before deduplication + let total_before = all_parsed_entities.len(); + + // Detect duplicates - this should succeed (duplicates handled gracefully) + // Using None for logger since we don't need to capture warnings in this test + let unique_entities = EntityParser::detect_duplicates(all_parsed_entities, &None); + + // Should have fewer unique entities than total (duplicates were merged) + assert!( + unique_entities.len() < total_before, + "Expected fewer unique entities ({}) than total ({}) due to duplicates", + unique_entities.len(), + total_before + ); + } + + #[test] + fn test_handles_unicode_in_filenames() { + let buffer = Vec::new(); + let cursor = Cursor::new(buffer); + let mut zip = ZipWriter::new(cursor); + + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + + // Unicode filename + zip.start_file("metadata.json", options.clone()).unwrap(); + zip.write_all(br#"{"cedar_version":"4.4.0","policy_store":{"id":"abc123def456","name":"Test","version":"1.0.0"}}"#).unwrap(); + + zip.start_file("schema.cedarschema", options.clone()) + .unwrap(); + zip.write_all(b"namespace Test { entity User; }").unwrap(); + + zip.start_file("policies/日本語ポリシー.cedar", options.clone()) + .unwrap(); + zip.write_all(br#"@id("japanese-policy") permit(principal, action, resource);"#) + .unwrap(); + + let archive = zip.finish().unwrap().into_inner(); + let result = ArchiveVfs::from_buffer(archive); + + // Should handle unicode gracefully + result.expect("ArchiveVfs should handle unicode filenames without error"); + } + + #[test] + fn test_handles_special_characters_in_policy_id() { + let builder = PolicyStoreTestBuilder::new("abc123def456").with_policy( + "special-chars", + r#"@id("policy-with-special-chars!@#$%") +permit(principal, action, resource);"#, + ); + + let archive = builder.build_archive().unwrap(); + let vfs = ArchiveVfs::from_buffer(archive).unwrap(); + let loader = DefaultPolicyStoreLoader::new(vfs); + + // Cedar allows special characters in @id() annotations within the policy content. + // The loader is expected to accept such policies successfully. + let loaded = loader + .load_directory(".") + .expect("Policy with special-character @id should load successfully"); + + // Verify policy was loaded with the special character ID + assert!( + !loaded.policies.is_empty(), + "Expected at least one policy to be loaded" + ); + } +} + +// ============================================================================ +// Manifest Security Tests +// ============================================================================ + +mod manifest_security { + use super::*; + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn test_detects_checksum_mismatch() { + use std::fs; + use std::io::Read; + use tempfile::TempDir; + use zip::read::ZipArchive; + + // Create a store with manifest + let builder = fixtures::minimal_valid().with_manifest(); + + // Build the archive + let archive = builder.build_archive().unwrap(); + + // Extract archive to temp directory first + let temp_dir = TempDir::new().unwrap(); + let mut zip_archive = ZipArchive::new(std::io::Cursor::new(&archive)).unwrap(); + + for i in 0..zip_archive.len() { + let mut file = zip_archive.by_index(i).unwrap(); + let file_path = temp_dir.path().join(file.name()); + + if file.is_dir() { + fs::create_dir_all(&file_path).unwrap(); + } else { + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let mut contents = Vec::new(); + file.read_to_end(&mut contents).unwrap(); + fs::write(&file_path, contents).unwrap(); + } + } + + // Modify schema.cedarschema to trigger checksum mismatch + // We modify a byte in the middle to avoid breaking the file structure + let schema_path = temp_dir.path().join("schema.cedarschema"); + + // Assert preconditions to ensure the test actually exercises checksum detection + assert!( + schema_path.exists(), + "schema.cedarschema must exist for checksum mismatch test" + ); + let mut schema_content = fs::read(&schema_path).unwrap(); + assert!( + schema_content.len() > 10, + "schema.cedarschema must be >10 bytes for mutation, got {} bytes", + schema_content.len() + ); + + // Modify a byte in the middle to change checksum without breaking structure + let mid_index = schema_content.len() / 2; + schema_content[mid_index] = schema_content[mid_index].wrapping_add(1); + fs::write(&schema_path, schema_content).unwrap(); + + // Attempt to load - should fail with checksum mismatch + // Use the synchronous load_directory method directly for testing + use super::super::loader::DefaultPolicyStoreLoader; + use super::super::vfs_adapter::PhysicalVfs; + let loader = DefaultPolicyStoreLoader::new(PhysicalVfs::new()); + let dir_str = temp_dir.path().to_str().unwrap(); + let loaded = loader.load_directory(dir_str).unwrap(); + + // Validate manifest - this should detect the checksum mismatch + let result = loader.validate_manifest(dir_str, &loaded.metadata, &loaded.manifest.unwrap()); + + let err = result.expect_err("expected checksum mismatch error"); + assert!( + matches!( + &err, + PolicyStoreError::ManifestError { + err: crate::common::policy_store::errors::ManifestErrorType::ChecksumMismatch { .. } + } + ), + "Expected ChecksumMismatch error, got: {:?}", + err + ); + } + + #[test] + fn test_handles_missing_manifest_gracefully() { + let builder = fixtures::minimal_valid(); + // No manifest + let archive = builder.build_archive().unwrap(); + + let vfs = ArchiveVfs::from_buffer(archive).unwrap(); + let loader = DefaultPolicyStoreLoader::new(vfs); + let result = loader.load_directory("."); + + // Should succeed without manifest + result.expect("minimal_valid store without manifest should load successfully"); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn test_handles_invalid_checksum_format() { + use super::super::loader::load_policy_store_directory; + use std::fs; + use std::io::Read; + use tempfile::TempDir; + use zip::read::ZipArchive; + + let mut builder = fixtures::minimal_valid(); + + // Add invalid manifest with bad checksum format. + // Use the same policy_store_id as the builder to avoid PolicyStoreIdMismatch error. + builder.extra_files.insert( + "manifest.json".to_string(), + r#"{ + "policy_store_id": "abc123def456", + "generated_date": "2024-01-01T00:00:00Z", + "files": { + "metadata.json": { + "size": 100, + "checksum": "invalid_format_no_sha256_prefix" + } + } + }"# + .to_string(), + ); + + let archive = builder.build_archive().unwrap(); + + // Extract archive to temp directory to test directory-based loading with manifest validation. + // We use `load_policy_store_directory` (not archive-based loading) because manifest validation + // is only performed for directory-based stores, not for archive-based loading. + let temp_dir = TempDir::new().unwrap(); + let mut zip_archive = ZipArchive::new(std::io::Cursor::new(&archive)).unwrap(); + + for i in 0..zip_archive.len() { + let mut file = zip_archive.by_index(i).unwrap(); + let file_path = temp_dir.path().join(file.name()); + + if file.is_dir() { + fs::create_dir_all(&file_path).unwrap(); + } else { + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let mut contents = Vec::new(); + file.read_to_end(&mut contents).unwrap(); + fs::write(&file_path, contents).unwrap(); + } + } + + // Use load_policy_store_directory which validates manifests + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(load_policy_store_directory(temp_dir.path())); + + // Invalid checksum format should be reported via ManifestError::InvalidChecksumFormat. + let err = result.expect_err( + "Expected ManifestError::InvalidChecksumFormat for invalid manifest checksum format", + ); + assert!( + matches!( + err, + PolicyStoreError::ManifestError { + err: crate::common::policy_store::errors::ManifestErrorType::InvalidChecksumFormat { .. } + } + ), + "Expected ManifestError::InvalidChecksumFormat for invalid manifest checksum, got: {:?}", + err + ); + } +} + +// ============================================================================ +// Resource Exhaustion Tests +// ============================================================================ + +mod resource_exhaustion { + use super::*; + + #[test] + fn test_handles_many_files() { + let mut builder = fixtures::minimal_valid(); + + // Add many policies + for i in 0..100 { + builder = builder.with_policy( + format!("policy{}", i), + format!(r#"@id("policy{}") permit(principal, action, resource);"#, i), + ); + } + + let archive = builder.build_archive().unwrap(); + let vfs = ArchiveVfs::from_buffer(archive).unwrap(); + let loader = DefaultPolicyStoreLoader::new(vfs); + let result = loader.load_directory("."); + + result.expect("Policy store with many policies should load successfully"); + } + + #[test] + fn test_handles_large_policy_content() { + // Create a policy with a very large condition + let large_condition = (0..1000) + .map(|i| format!("principal.attr{} == \"value{}\"", i, i)) + .collect::>() + .join(" || "); + + let policy = format!( + r#"@id("large-policy") +permit(principal, action, resource) +when {{ {} }};"#, + large_condition + ); + + let builder = + PolicyStoreTestBuilder::new("abc123def456").with_policy("large-policy", &policy); + + let archive = builder.build_archive().unwrap(); + let vfs = ArchiveVfs::from_buffer(archive).unwrap(); + let loader = DefaultPolicyStoreLoader::new(vfs); + + // Large policies should be handled gracefully + let result = loader.load_directory("."); + + // Verify loading succeeds - Cedar can handle large policies + result.expect("Large policy should load successfully"); + } + + #[test] + fn test_handles_deeply_nested_entity_hierarchy() { + // Create a deep entity hierarchy (should be bounded) + let mut entities = Vec::new(); + + // Create 50 levels of roles + for i in 0..50 { + let parents = if i > 0 { + vec![serde_json::json!({"type": "TestApp::Role", "id": format!("role{}", i - 1)})] + } else { + vec![] + }; + + entities.push(serde_json::json!({ + "uid": {"type": "TestApp::Role", "id": format!("role{}", i)}, + "attrs": {"level": i}, + "parents": parents + })); + } + + let builder = fixtures::minimal_valid() + .with_entity("deep_roles", serde_json::to_string(&entities).unwrap()); + + let archive = builder.build_archive().unwrap(); + let vfs = ArchiveVfs::from_buffer(archive).unwrap(); + let loader = DefaultPolicyStoreLoader::new(vfs); + let result = loader.load_directory("."); + + // Should handle deep hierarchy + result.expect("Policy store with deeply nested entity hierarchy should load successfully"); + } +} + +// ============================================================================ +// File Extension Validation Tests +// ============================================================================ + +mod file_extension_validation { + use super::*; + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn test_rejects_wrong_archive_extension() { + use tempfile::TempDir; + + let builder = fixtures::minimal_valid(); + let archive_bytes = builder.build_archive().unwrap(); + + let temp_dir = TempDir::new().unwrap(); + let wrong_ext = temp_dir.path().join("store.zip"); + std::fs::write(&wrong_ext, &archive_bytes).unwrap(); + + let result = ArchiveVfs::from_file(&wrong_ext); + let err = result.expect_err("Expected InvalidExtension error"); + assert!( + matches!(err, ArchiveError::InvalidExtension { .. }), + "Expected InvalidExtension error, got: {:?}", + err + ); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn test_accepts_cjar_extension() { + use tempfile::TempDir; + + let builder = fixtures::minimal_valid(); + let archive_bytes = builder.build_archive().unwrap(); + + let temp_dir = TempDir::new().unwrap(); + let correct_ext = temp_dir.path().join("store.cjar"); + std::fs::write(&correct_ext, &archive_bytes).unwrap(); + + let result = ArchiveVfs::from_file(&correct_ext); + result.expect("ArchiveVfs should accept .cjar extension"); + } +} diff --git a/jans-cedarling/cedarling/src/common/policy_store/claim_mapping.rs b/jans-cedarling/cedarling/src/common/policy_store/claim_mapping.rs index 66359114587..0708a7bb600 100644 --- a/jans-cedarling/cedarling/src/common/policy_store/claim_mapping.rs +++ b/jans-cedarling/cedarling/src/common/policy_store/claim_mapping.rs @@ -136,8 +136,8 @@ pub struct RegexMapping { } impl RegexMapping { - // builder function, used in testing - #[allow(dead_code)] + /// Create a new RegexMapping with the given expression and field mappings. + #[cfg(test)] fn new( regex_expression: String, fields: HashMap, @@ -313,14 +313,20 @@ mod test { let re_mapping = RegexMapping::new( r#"^(?P[^@]+)@(?P.+)$"#.to_string(), HashMap::from([ - ("UID".to_string(), RegexFieldMapping { - attr: "uid".to_string(), - r#type: RegexFieldMappingType::String, - }), - ("DOMAIN".to_string(), RegexFieldMapping { - attr: "domain".to_string(), - r#type: RegexFieldMappingType::String, - }), + ( + "UID".to_string(), + RegexFieldMapping { + attr: "uid".to_string(), + r#type: RegexFieldMappingType::String, + }, + ), + ( + "DOMAIN".to_string(), + RegexFieldMapping { + attr: "domain".to_string(), + r#type: RegexFieldMappingType::String, + }, + ), ]), ) .expect("regexp should parse correctly"); @@ -384,15 +390,21 @@ mod test { let re_mapping = RegexMapping::new( r#"^(?P[^@]+)@(?P.+)$"#.to_string(), HashMap::from([ - ("UID".to_string(), RegexFieldMapping { - attr: "uid".to_string(), - r#type: RegexFieldMappingType::String, - }), - ("DOMAIN".to_string(), RegexFieldMapping { - attr: "domain".to_string(), - - r#type: RegexFieldMappingType::String, - }), + ( + "UID".to_string(), + RegexFieldMapping { + attr: "uid".to_string(), + r#type: RegexFieldMappingType::String, + }, + ), + ( + "DOMAIN".to_string(), + RegexFieldMapping { + attr: "domain".to_string(), + + r#type: RegexFieldMappingType::String, + }, + ), ]), ) .expect("regexp should parse correctly"); diff --git a/jans-cedarling/cedarling/src/common/policy_store/entity_parser.rs b/jans-cedarling/cedarling/src/common/policy_store/entity_parser.rs new file mode 100644 index 00000000000..316e79c5594 --- /dev/null +++ b/jans-cedarling/cedarling/src/common/policy_store/entity_parser.rs @@ -0,0 +1,667 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Cedar entity parsing and validation. +//! +//! This module provides functionality to parse and validate Cedar entity files in JSON format, +//! ensuring they conform to Cedar's entity specification with proper UIDs, attributes, and +//! parent relationships. + +use super::errors::{CedarEntityErrorType, PolicyStoreError}; +use super::log_entry::PolicyStoreLogEntry; +use crate::log::Logger; +use crate::log::interface::LogWriter; +use cedar_policy::{Entities, Entity, EntityId, EntityTypeName, EntityUid, Schema}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; + +/// A parsed Cedar entity with metadata. +/// +/// Contains the Cedar entity and metadata about the source file. +#[derive(Debug, Clone)] +pub struct ParsedEntity { + /// The Cedar entity + pub entity: Entity, + /// The entity's UID + pub uid: EntityUid, + /// Source filename + pub filename: String, + /// Raw entity content (JSON) + pub content: String, +} + +/// Raw entity JSON structure as expected by Cedar. +/// +/// This matches Cedar's JSON entity format with uid, attrs, and parents fields. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawEntityJson { + /// Entity unique identifier + pub uid: EntityUidJson, + /// Entity attributes as a map of attribute names to values (optional) + #[serde(default)] + pub attrs: HashMap, + /// Parent entity UIDs for hierarchy (optional) + #[serde(default)] + pub parents: Vec, +} + +/// Entity UID in JSON format. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct EntityUidJson { + /// Entity type (e.g., "Jans::User") + #[serde(rename = "type")] + pub entity_type: String, + /// Entity ID + pub id: String, +} + +/// Wrapper for multiple entities that can be in array or object format. +/// +/// This supports both formats commonly used in Cedar entity files: +/// - Array: `[{entity1}, {entity2}]` +/// - Object: `{"id1": {entity1}, "id2": {entity2}}` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +enum RawEntitiesWrapper { + /// Array of entity objects + Array(Vec), + /// Object mapping IDs to entity objects + Object(HashMap), +} + +/// Entity parser for loading and validating Cedar entities. +pub struct EntityParser; + +impl EntityParser { + /// Parse a single Cedar entity from JSON value. + /// + /// Validates the entity structure, parses the UID, attributes, and parent relationships. + /// Optionally validates entity attributes against a schema. + /// + /// # Errors + /// Returns `PolicyStoreError` if: + /// - JSON parsing fails + /// - Entity structure is invalid + /// - UID format is invalid + /// - Parent UID format is invalid + /// - Schema validation fails (if schema provided) + pub fn parse_entity( + entity_json: &JsonValue, + filename: &str, + schema: Option<&Schema>, + ) -> Result { + // Parse the JSON structure + let raw_entity: RawEntityJson = + serde_json::from_value(entity_json.clone()).map_err(|e| { + PolicyStoreError::JsonParsing { + file: filename.to_string(), + source: e, + } + })?; + + // Parse the entity UID + let uid = Self::parse_entity_uid(&raw_entity.uid, filename)?; + + // Validate parent UIDs format (don't collect, just validate) + for parent_uid in &raw_entity.parents { + Self::parse_entity_uid(parent_uid, filename)?; + } + + // Use Cedar's Entity::from_json_value to parse the entity with attributes + // This properly handles attribute conversion to RestrictedExpression + let entity_json_for_cedar = serde_json::json!({ + "uid": { + "type": raw_entity.uid.entity_type, + "id": raw_entity.uid.id + }, + "attrs": raw_entity.attrs, + "parents": raw_entity.parents + }); + + // Parse with optional schema validation using Entity::from_json_value + let entity = Entity::from_json_value(entity_json_for_cedar, schema).map_err(|e| { + PolicyStoreError::CedarEntityError { + file: filename.to_string(), + err: CedarEntityErrorType::JsonParseError(format!( + "Failed to parse entity{}: {}", + if schema.is_some() { + " (schema validation failed)" + } else { + "" + }, + e + )), + } + })?; + + Ok(ParsedEntity { + entity, + uid, + filename: filename.to_string(), + content: serde_json::to_string(entity_json).unwrap_or_default(), + }) + } + + /// Parse multiple entities from a JSON array or object. + /// + /// Supports both array format: `[{entity1}, {entity2}]` + /// And object format: `{"entity_id1": {entity1}, "entity_id2": {entity2}}` + pub fn parse_entities( + content: &str, + filename: &str, + schema: Option<&Schema>, + ) -> Result, PolicyStoreError> { + let json_value: JsonValue = + serde_json::from_str(content).map_err(|e| PolicyStoreError::JsonParsing { + file: filename.to_string(), + source: e, + })?; + + // Use untagged enum to handle both array and object formats + let wrapper: RawEntitiesWrapper = + serde_json::from_value(json_value).map_err(|e| PolicyStoreError::CedarEntityError { + file: filename.to_string(), + err: CedarEntityErrorType::JsonParseError(format!( + "Entity file must contain a JSON array or object: {}", + e + )), + })?; + + let entity_values: Vec<&JsonValue> = match &wrapper { + RawEntitiesWrapper::Array(arr) => arr.iter().collect(), + RawEntitiesWrapper::Object(obj) => obj.values().collect(), + }; + + let mut parsed_entities = Vec::with_capacity(entity_values.len()); + for entity_json in entity_values { + let parsed = Self::parse_entity(entity_json, filename, schema)?; + parsed_entities.push(parsed); + } + + Ok(parsed_entities) + } + + /// Parse an EntityUid from JSON format. + fn parse_entity_uid( + uid_json: &EntityUidJson, + filename: &str, + ) -> Result { + // Parse the entity type name + let entity_type = EntityTypeName::from_str(&uid_json.entity_type).map_err(|e| { + PolicyStoreError::CedarEntityError { + file: filename.to_string(), + err: CedarEntityErrorType::InvalidTypeName( + uid_json.entity_type.clone(), + e.to_string(), + ), + } + })?; + + // Parse the entity ID + let entity_id = + EntityId::from_str(&uid_json.id).map_err(|e| PolicyStoreError::CedarEntityError { + file: filename.to_string(), + err: CedarEntityErrorType::InvalidEntityId(format!( + "Invalid entity ID '{}': {}", + uid_json.id, e + )), + })?; + + Ok(EntityUid::from_type_name_and_id(entity_type, entity_id)) + } + + /// Detect and handle duplicate entity UIDs. + /// + /// Returns a map of entity UIDs to their parsed entities. + /// If duplicates are found, logs warnings and uses the latest entity (last-write-wins). + /// This approach ensures Cedarling can start even with duplicate entities, + /// avoiding crashes of dependent applications while still alerting developers. + pub fn detect_duplicates( + entities: Vec, + logger: &Option, + ) -> HashMap { + let mut entity_map: HashMap = + HashMap::with_capacity(entities.len()); + + for entity in entities { + if let Some(existing) = entity_map.get(&entity.uid) { + // Warn about duplicate but continue - use the latest entity + logger.log_any(PolicyStoreLogEntry::warn(format!( + "Duplicate entity UID '{}' found in files '{}' and '{}'. Using the latter.", + entity.uid, existing.filename, entity.filename + ))); + } + // Always insert - latest entity wins (last-write-wins semantics) + entity_map.insert(entity.uid.clone(), entity); + } + + entity_map + } + + /// Create a Cedar Entities store from parsed entities. + /// + /// Validates that all entities are compatible and can be used together. + pub fn create_entities_store( + entities: Vec, + ) -> Result { + let entity_list: Vec = entities.into_iter().map(|p| p.entity).collect(); + + Entities::from_entities(entity_list, None).map_err(|e| PolicyStoreError::CedarEntityError { + file: "entity_store".to_string(), + err: CedarEntityErrorType::EntityStoreCreation(e.to_string()), + }) + } + + /// Validate entity hierarchy. + /// + /// Ensures that all parent references point to entities that exist in the collection. + pub fn validate_hierarchy(entities: &[ParsedEntity]) -> Result<(), Vec> { + let entity_uids: HashSet<&EntityUid> = entities.iter().map(|e| &e.uid).collect(); + let mut errors: Vec = Vec::new(); + + for parsed_entity in entities { + // Get parents directly from the entity using into_inner() + // into_inner() returns (uid, attrs, parents) + let parents = &parsed_entity.entity.clone().into_inner().2; + + for parent_uid in parents { + if !entity_uids.contains(parent_uid) { + errors.push(format!( + "Entity '{}' in file '{}' references non-existent parent '{}'", + parsed_entity.uid, parsed_entity.filename, parent_uid + )); + } + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_entity() { + let content = serde_json::json!({ + "uid": { + "type": "User", + "id": "alice" + }, + "attrs": { + "name": "Alice", + "age": 30 + }, + "parents": [] + }); + + let result = EntityParser::parse_entity(&content, "user1.json", None); + assert!( + result.is_ok(), + "Should parse simple entity: {:?}", + result.err() + ); + + let parsed = result.unwrap(); + assert_eq!(parsed.filename, "user1.json"); + assert_eq!(parsed.uid.to_string(), "User::\"alice\""); + } + + #[test] + fn test_parse_entity_with_parents() { + let content = serde_json::json!({ + "uid": { + "type": "User", + "id": "bob" + }, + "attrs": { + "name": "Bob" + }, + "parents": [ + { + "type": "Role", + "id": "admin" + }, + { + "type": "Role", + "id": "developer" + } + ] + }); + + let parsed = EntityParser::parse_entity(&content, "user2.json", None) + .expect("Should parse entity with parents"); + // Verify parents using into_inner() + let parents = &parsed.entity.clone().into_inner().2; + assert_eq!(parents.len(), 2, "Should have 2 parents"); + } + + #[test] + fn test_parse_entity_with_namespace() { + let content = serde_json::json!({ + "uid": { + "type": "Jans::User", + "id": "user123" + }, + "attrs": { + "email": "user@example.com" + }, + "parents": [] + }); + + let parsed = EntityParser::parse_entity(&content, "jans_user.json", None) + .expect("Should parse entity with namespace"); + assert_eq!(parsed.uid.to_string(), "Jans::User::\"user123\""); + } + + #[test] + fn test_parse_entity_empty_attrs() { + let content = serde_json::json!({ + "uid": { + "type": "Resource", + "id": "res1" + }, + "attrs": {}, + "parents": [] + }); + + EntityParser::parse_entity(&content, "resource.json", None) + .expect("Should parse entity with empty attrs"); + } + + #[test] + fn test_parse_entity_invalid_json() { + let content = serde_json::json!("not an object"); + + let result = EntityParser::parse_entity(&content, "invalid.json", None); + let err = result.expect_err("Should fail on invalid JSON"); + + assert!( + matches!(&err, PolicyStoreError::JsonParsing { file, .. } if file == "invalid.json"), + "Expected JsonParsing error, got: {:?}", + err + ); + } + + #[test] + fn test_parse_entity_invalid_type() { + let content = serde_json::json!({ + "uid": { + "type": "Invalid Type Name!", + "id": "test" + }, + "attrs": {}, + "parents": [] + }); + + let result = EntityParser::parse_entity(&content, "invalid_type.json", None); + let err = result.expect_err("Should fail on invalid entity type"); + assert!( + matches!(&err, PolicyStoreError::CedarEntityError { .. }), + "Expected CedarEntityError for invalid entity type, got: {:?}", + err + ); + } + + #[test] + fn test_parse_entities_array() { + let content = r#"[ + { + "uid": {"type": "User", "id": "user1"}, + "attrs": {"name": "User One"}, + "parents": [] + }, + { + "uid": {"type": "User", "id": "user2"}, + "attrs": {"name": "User Two"}, + "parents": [] + } + ]"#; + + let parsed = EntityParser::parse_entities(content, "users.json", None) + .expect("Should parse entity array"); + assert_eq!(parsed.len(), 2, "Should have 2 entities"); + } + + #[test] + fn test_parse_entities_object() { + let content = r#"{ + "user1": { + "uid": {"type": "User", "id": "user1"}, + "attrs": {}, + "parents": [] + }, + "user2": { + "uid": {"type": "User", "id": "user2"}, + "attrs": {}, + "parents": [] + } + }"#; + + let parsed = EntityParser::parse_entities(content, "users.json", None) + .expect("Should parse entity object"); + assert_eq!(parsed.len(), 2, "Should have 2 entities"); + } + + #[test] + fn test_detect_duplicates_none() { + let entities = vec![ + ParsedEntity { + entity: Entity::new( + "User::\"alice\"".parse().unwrap(), + HashMap::new(), + HashSet::new(), + ) + .unwrap(), + uid: "User::\"alice\"".parse().unwrap(), + filename: "user1.json".to_string(), + content: String::new(), + }, + ParsedEntity { + entity: Entity::new( + "User::\"bob\"".parse().unwrap(), + HashMap::new(), + HashSet::new(), + ) + .unwrap(), + uid: "User::\"bob\"".parse().unwrap(), + filename: "user2.json".to_string(), + content: String::new(), + }, + ]; + + // No logger needed for this test - duplicates are handled gracefully + let map = EntityParser::detect_duplicates(entities, &None); + assert_eq!(map.len(), 2, "Should have 2 unique entities"); + } + + #[test] + fn test_detect_duplicates_uses_latest() { + // Create two entities with the same UID but different filenames + // The second (latest) one should be used + let entities = vec![ + ParsedEntity { + entity: Entity::new( + "User::\"alice\"".parse().unwrap(), + HashMap::new(), + HashSet::new(), + ) + .unwrap(), + uid: "User::\"alice\"".parse().unwrap(), + filename: "user1.json".to_string(), + content: String::new(), + }, + ParsedEntity { + entity: Entity::new( + "User::\"alice\"".parse().unwrap(), + HashMap::new(), + HashSet::new(), + ) + .unwrap(), + uid: "User::\"alice\"".parse().unwrap(), + filename: "user2.json".to_string(), + content: String::new(), + }, + ]; + + // Duplicates should be handled gracefully - no error, just warning (no logger here) + let map = EntityParser::detect_duplicates(entities, &None); + + // Should have 1 unique entity (the duplicate was handled) + assert_eq!( + map.len(), + 1, + "Should have 1 unique entity after handling duplicate" + ); + + // The latest entity (from user2.json) should be used + let alice = map.get(&"User::\"alice\"".parse().unwrap()).unwrap(); + assert_eq!( + alice.filename, "user2.json", + "Should use the latest entity (last-write-wins)" + ); + } + + #[test] + fn test_validate_hierarchy_valid() { + // Create parent entity + let parent = ParsedEntity { + entity: Entity::new( + "Role::\"admin\"".parse().unwrap(), + HashMap::new(), + HashSet::new(), + ) + .unwrap(), + uid: "Role::\"admin\"".parse().unwrap(), + filename: "role.json".to_string(), + content: String::new(), + }; + + // Create child entity with parent reference + let mut parent_set = HashSet::new(); + parent_set.insert("Role::\"admin\"".parse().unwrap()); + + let child = ParsedEntity { + entity: Entity::new( + "User::\"alice\"".parse().unwrap(), + HashMap::new(), + parent_set, + ) + .unwrap(), + uid: "User::\"alice\"".parse().unwrap(), + filename: "user.json".to_string(), + content: String::new(), + }; + + let entities = vec![parent, child]; + EntityParser::validate_hierarchy(&entities).expect("Hierarchy should be valid"); + } + + #[test] + fn test_validate_hierarchy_missing_parent() { + // Create child entity with non-existent parent reference + let mut parent_set = HashSet::new(); + parent_set.insert("Role::\"admin\"".parse().unwrap()); + + let child = ParsedEntity { + entity: Entity::new( + "User::\"alice\"".parse().unwrap(), + HashMap::new(), + parent_set, + ) + .unwrap(), + uid: "User::\"alice\"".parse().unwrap(), + filename: "user.json".to_string(), + content: String::new(), + }; + + let entities = vec![child]; + let result = EntityParser::validate_hierarchy(&entities); + let errors = result.expect_err("Should detect missing parent"); + + assert_eq!(errors.len(), 1, "Should have 1 hierarchy error"); + assert!( + errors[0].contains("Role::\"admin\""), + "Error should reference missing parent Role::admin, got: {}", + errors[0] + ); + } + + #[test] + fn test_create_entities_store() { + let entities = vec![ + ParsedEntity { + entity: Entity::new( + "User::\"alice\"".parse().unwrap(), + HashMap::new(), + HashSet::new(), + ) + .unwrap(), + uid: "User::\"alice\"".parse().unwrap(), + filename: "user1.json".to_string(), + content: String::new(), + }, + ParsedEntity { + entity: Entity::new( + "User::\"bob\"".parse().unwrap(), + HashMap::new(), + HashSet::new(), + ) + .unwrap(), + uid: "User::\"bob\"".parse().unwrap(), + filename: "user2.json".to_string(), + content: String::new(), + }, + ]; + + let store = + EntityParser::create_entities_store(entities).expect("Should create entity store"); + assert_eq!(store.iter().count(), 2, "Store should have 2 entities"); + } + + #[test] + fn test_parse_entity_with_schema_validation() { + use cedar_policy::{Schema, SchemaFragment}; + use std::str::FromStr; + + // Create a schema that defines User entity type + let schema_src = r#" + entity User = { + name: String, + age: Long + }; + "#; + + let fragment = SchemaFragment::from_str(schema_src).expect("Should parse schema"); + let schema = Schema::from_schema_fragments([fragment]).expect("Should create schema"); + + // Valid entity matching schema + let valid_content = serde_json::json!({ + "uid": { + "type": "User", + "id": "alice" + }, + "attrs": { + "name": "Alice", + "age": 30 + }, + "parents": [] + }); + + let result = EntityParser::parse_entity(&valid_content, "user.json", Some(&schema)); + assert!( + result.is_ok(), + "Should parse entity with valid schema: {:?}", + result.err() + ); + } +} diff --git a/jans-cedarling/cedarling/src/common/policy_store/errors.rs b/jans-cedarling/cedarling/src/common/policy_store/errors.rs new file mode 100644 index 00000000000..dc9680ac534 --- /dev/null +++ b/jans-cedarling/cedarling/src/common/policy_store/errors.rs @@ -0,0 +1,343 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Error types for policy store operations. + +/// Cedar schema-specific errors. +#[derive(Debug, thiserror::Error)] +pub enum CedarSchemaErrorType { + /// Schema file is empty + #[error("Schema file is empty")] + EmptySchema, + + /// Schema parsing failed + #[error("Schema parsing failed: {0}")] + ParseError(String), + + /// Schema validation failed + #[error("Schema validation failed: {0}")] + ValidationError(String), +} + +/// Cedar entity-specific errors. +#[derive(Debug, thiserror::Error)] +pub enum CedarEntityErrorType { + /// Failed to parse entity from JSON + #[error("Failed to parse entity from JSON: {0}")] + JsonParseError(String), + + /// Invalid entity type name + #[error("Invalid entity type name '{0}': {1}")] + InvalidTypeName(String, String), + + /// Invalid entity ID + #[error("Invalid entity ID: {0}")] + InvalidEntityId(String), + + /// Failed to create entity store + #[error("Failed to create entity store: {0}")] + EntityStoreCreation(String), +} + +/// Trusted issuer-specific errors. +#[derive(Debug, thiserror::Error)] +pub enum TrustedIssuerErrorType { + /// Trusted issuer file is not a JSON object + #[error("Trusted issuer file must be a JSON object")] + NotAnObject, + + /// Issuer configuration is not an object + #[error("Issuer '{issuer_id}' must be a JSON object")] + IssuerNotAnObject { issuer_id: String }, + + /// Missing required field in issuer configuration + #[error("Issuer '{issuer_id}': missing required field '{field}'")] + MissingRequiredField { issuer_id: String, field: String }, + + /// Invalid OIDC endpoint URL + #[error("Issuer '{issuer_id}': invalid OIDC endpoint URL '{url}': {reason}")] + InvalidOidcEndpoint { + issuer_id: String, + url: String, + reason: String, + }, + + /// Token metadata is not an object + #[error("Issuer '{issuer_id}': token_metadata must be a JSON object")] + TokenMetadataNotAnObject { issuer_id: String }, + + /// Token metadata entry is not an object + #[error("Issuer '{issuer_id}': token_metadata.{token_type} must be a JSON object")] + TokenMetadataEntryNotAnObject { + issuer_id: String, + token_type: String, + }, +} + +/// Manifest validation-specific errors. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +#[cfg(not(target_arch = "wasm32"))] +pub enum ManifestErrorType { + /// Manifest file not found + #[error("Manifest file not found (manifest.json is required for integrity validation)")] + ManifestNotFound, + + /// Manifest parsing failed + #[error("Failed to parse manifest: {0}")] + ParseError(String), + + /// File listed in manifest is missing from policy store + #[error("File '{file}' is listed in manifest but not found in policy store")] + FileMissing { file: String }, + + /// Error reading file from policy store + #[error("Failed to read file '{file}': {error_message}")] + FileReadError { file: String, error_message: String }, + + /// File checksum mismatch + #[error("Checksum mismatch for '{file}': expected '{expected}', computed '{actual}'")] + ChecksumMismatch { + file: String, + expected: String, + actual: String, + }, + + /// Invalid checksum format + #[error("Invalid checksum format for '{file}': expected 'sha256:', found '{checksum}'")] + InvalidChecksumFormat { file: String, checksum: String }, + + /// File size mismatch + #[error("Size mismatch for '{file}': expected {expected} bytes, found {actual} bytes")] + SizeMismatch { + file: String, + expected: u64, + actual: u64, + }, + + /// Policy store ID mismatch + #[error("Policy store ID mismatch: manifest expects '{expected}', metadata has '{actual}'")] + PolicyStoreIdMismatch { expected: String, actual: String }, +} + +/// Errors that can occur during policy store operations. +#[derive(Debug, thiserror::Error)] +pub enum PolicyStoreError { + /// IO error during file operations + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + /// Validation error + #[error("Validation error: {0}")] + Validation(#[from] ValidationError), + + /// Archive handling error + #[error("Archive error: {0}")] + Archive(#[from] ArchiveError), + + /// JSON parsing error + #[error("JSON parsing error in '{file}'")] + JsonParsing { + file: String, + #[source] + source: serde_json::Error, + }, + + /// Cedar parsing error + #[error("Cedar parsing error in '{file}': {detail}")] + CedarParsing { + file: String, + detail: CedarParseErrorDetail, + }, + + /// Cedar schema error + #[error("Cedar schema error in '{file}': {err}")] + CedarSchemaError { + file: String, + err: CedarSchemaErrorType, + }, + + /// Cedar entity error + #[error("Cedar entity error in '{file}': {err}")] + CedarEntityError { + file: String, + err: CedarEntityErrorType, + }, + + /// Trusted issuer error + #[error("Trusted issuer error in '{file}': {err}")] + TrustedIssuerError { + file: String, + err: TrustedIssuerErrorType, + }, + + /// Manifest validation error + #[error("Manifest validation error: {err}")] + #[cfg(not(target_arch = "wasm32"))] + ManifestError { err: ManifestErrorType }, + + /// Path not found + #[error("Path not found: {path}")] + PathNotFound { path: String }, + + /// Path is not a directory + #[error("Path is not a directory: {path}")] + NotADirectory { path: String }, + + /// Directory read error + #[error("Failed to read directory '{path}'")] + DirectoryReadError { + path: String, + #[source] + source: std::io::Error, + }, + + /// File read error + #[error("Failed to read file '{path}'")] + FileReadError { + path: String, + #[source] + source: std::io::Error, + }, +} + +/// Details about Cedar parsing errors. +#[derive(Debug, Clone, thiserror::Error)] +pub enum CedarParseErrorDetail { + /// Missing @id() annotation + #[error("No @id() annotation found and could not derive ID from filename")] + MissingIdAnnotation, + + /// Failed to parse Cedar policy or template + #[error("{0}")] + ParseError(String), + + /// Failed to add policy to policy set + #[error("Failed to add policy to set: {0}")] + AddPolicyFailed(String), + + /// Failed to add template to policy set + #[error("Failed to add template to set: {0}")] + AddTemplateFailed(String), +} + +/// Validation errors for policy store components. +#[derive(Debug, thiserror::Error)] +pub enum ValidationError { + /// Failed to parse metadata JSON + #[error("Invalid metadata in file {file}: failed to parse JSON")] + MetadataJsonParseFailed { + file: String, + #[source] + source: serde_json::Error, + }, + + /// Invalid cedar version format in metadata + #[error("Invalid metadata in file {file}: invalid cedar_version format")] + MetadataInvalidCedarVersion { + file: String, + #[source] + source: semver::Error, + }, + + /// Missing required file + #[error("Missing required file: {file}")] + MissingRequiredFile { file: String }, + + /// Missing required directory + #[error("Missing required directory: {directory}")] + MissingRequiredDirectory { directory: String }, + + /// Invalid file extension + #[error("Invalid file extension for {file}: expected {expected}, got {actual}")] + InvalidFileExtension { + file: String, + expected: String, + actual: String, + }, + + /// Policy ID is empty + #[error("Invalid policy ID format in {file}: Policy ID cannot be empty")] + EmptyPolicyId { file: String }, + + /// Policy ID contains invalid characters + #[error( + "Invalid policy ID format in {file}: Policy ID '{id}' contains invalid characters. Only alphanumeric, '_', '-', and ':' are allowed" + )] + InvalidPolicyIdCharacters { file: String, id: String }, + + // Specific metadata validation errors + /// Empty Cedar version + #[error("Cedar version cannot be empty in metadata.json")] + EmptyCedarVersion, + + /// Invalid Cedar version format + #[error("Invalid Cedar version format in metadata.json: '{version}' - {details}")] + InvalidCedarVersion { version: String, details: String }, + + /// Empty policy store name + #[error("Policy store name cannot be empty in metadata.json")] + EmptyPolicyStoreName, + + /// Policy store name too long + #[error("Policy store name too long in metadata.json: {length} chars (max 255)")] + PolicyStoreNameTooLong { length: usize }, + + /// Invalid policy store ID format + #[error( + "Invalid policy store ID format in metadata.json: '{id}' must be hexadecimal (8-64 chars)" + )] + InvalidPolicyStoreId { id: String }, + + /// Invalid policy store version + #[error("Invalid policy store version in metadata.json: '{version}' - {details}")] + InvalidPolicyStoreVersion { version: String, details: String }, + + /// Policy store description too long + #[error( + "Policy store description too long in metadata.json: {length} chars (max {max_length})" + )] + DescriptionTooLong { length: usize, max_length: usize }, + + /// Invalid timestamp ordering + #[error( + "Invalid timestamp ordering in metadata.json: updated_date cannot be before created_date" + )] + InvalidTimestampOrdering, +} + +/// Errors related to archive (.cjar) handling. +#[derive(Debug, thiserror::Error)] +pub enum ArchiveError { + /// Invalid file extension (expected .cjar) + #[error("Invalid file extension: expected '{expected}', found '{found}'")] + #[cfg(not(target_arch = "wasm32"))] + InvalidExtension { expected: String, found: String }, + + /// Cannot read archive file + #[error("Cannot read archive file '{path}': {source}")] + #[cfg(not(target_arch = "wasm32"))] + CannotReadFile { + path: String, + #[source] + source: std::io::Error, + }, + + /// Invalid ZIP format + #[error("Invalid ZIP archive format: {details}")] + InvalidZipFormat { details: String }, + + /// Corrupted archive entry + #[error("Corrupted archive entry at index {index}: {details}")] + CorruptedEntry { index: usize, details: String }, + + /// Path traversal attempt detected + #[error("Path traversal attempt detected in archive: '{path}'")] + PathTraversal { path: String }, + + /// Unsupported operation on this platform + #[cfg(target_arch = "wasm32")] + #[error("Archive operations are not supported on this platform")] + WasmUnsupported, +} diff --git a/jans-cedarling/cedarling/src/common/policy_store/issuer_parser.rs b/jans-cedarling/cedarling/src/common/policy_store/issuer_parser.rs new file mode 100644 index 00000000000..83e3e18eecf --- /dev/null +++ b/jans-cedarling/cedarling/src/common/policy_store/issuer_parser.rs @@ -0,0 +1,611 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Trusted issuer configuration parsing and validation. +//! +//! This module provides functionality to parse and validate trusted issuer configuration files, +//! ensuring they conform to the required schema with proper token metadata and required fields. + +use super::errors::{PolicyStoreError, TrustedIssuerErrorType}; +use super::{TokenEntityMetadata, TrustedIssuer}; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use url::Url; + +/// A parsed trusted issuer configuration with metadata. +#[derive(Debug, Clone)] +pub struct ParsedIssuer { + /// The issuer name (used as key/id) + pub id: String, + /// The trusted issuer configuration + pub issuer: TrustedIssuer, + /// Source filename + pub filename: String, +} + +/// Issuer parser for loading and validating trusted issuer configurations. +pub struct IssuerParser; + +impl IssuerParser { + /// Parse a trusted issuer configuration from JSON content. + /// + /// Validates the required fields and token metadata structure. + pub fn parse_issuer( + content: &str, + filename: &str, + ) -> Result, PolicyStoreError> { + // Parse JSON + let json_value: JsonValue = + serde_json::from_str(content).map_err(|e| PolicyStoreError::JsonParsing { + file: filename.to_string(), + source: e, + })?; + + // Trusted issuer files should be objects mapping issuer IDs to configurations + let obj = json_value + .as_object() + .ok_or_else(|| PolicyStoreError::TrustedIssuerError { + file: filename.to_string(), + err: TrustedIssuerErrorType::NotAnObject, + })?; + + let mut parsed_issuers = Vec::with_capacity(obj.len()); + + for (issuer_id, issuer_json) in obj { + // Validate and parse the issuer configuration + let issuer = Self::parse_single_issuer(issuer_json, issuer_id, filename)?; + + // Store only this issuer's JSON, not the entire file content + parsed_issuers.push(ParsedIssuer { + id: issuer_id.clone(), + issuer, + filename: filename.to_string(), + }); + } + + Ok(parsed_issuers) + } + + /// Parse a single trusted issuer configuration. + fn parse_single_issuer( + issuer_json: &JsonValue, + issuer_id: &str, + filename: &str, + ) -> Result { + let obj = issuer_json + .as_object() + .ok_or_else(|| PolicyStoreError::TrustedIssuerError { + file: filename.to_string(), + err: TrustedIssuerErrorType::IssuerNotAnObject { + issuer_id: issuer_id.to_string(), + }, + })?; + + // Validate required fields + let name = obj.get("name").and_then(|v| v.as_str()).ok_or_else(|| { + PolicyStoreError::TrustedIssuerError { + file: filename.to_string(), + err: TrustedIssuerErrorType::MissingRequiredField { + issuer_id: issuer_id.to_string(), + field: "name".to_string(), + }, + } + })?; + + let description = obj + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // Validate openid_configuration_endpoint + let oidc_endpoint_str = obj + .get("openid_configuration_endpoint") + .and_then(|v| v.as_str()) + .ok_or_else(|| PolicyStoreError::TrustedIssuerError { + file: filename.to_string(), + err: TrustedIssuerErrorType::MissingRequiredField { + issuer_id: issuer_id.to_string(), + field: "openid_configuration_endpoint".to_string(), + }, + })?; + + let oidc_endpoint = + Url::parse(oidc_endpoint_str).map_err(|e| PolicyStoreError::TrustedIssuerError { + file: filename.to_string(), + err: TrustedIssuerErrorType::InvalidOidcEndpoint { + issuer_id: issuer_id.to_string(), + url: oidc_endpoint_str.to_string(), + reason: e.to_string(), + }, + })?; + + // Parse token_metadata (optional but recommended) + let token_metadata = if let Some(metadata_json) = obj.get("token_metadata") { + Self::parse_token_metadata(metadata_json, issuer_id, filename)? + } else { + HashMap::new() + }; + + Ok(TrustedIssuer { + name: name.to_string(), + description: description.to_string(), + oidc_endpoint, + token_metadata, + }) + } + + /// Parse token metadata configurations. + fn parse_token_metadata( + metadata_json: &JsonValue, + issuer_id: &str, + filename: &str, + ) -> Result, PolicyStoreError> { + let metadata_obj = + metadata_json + .as_object() + .ok_or_else(|| PolicyStoreError::TrustedIssuerError { + file: filename.to_string(), + err: TrustedIssuerErrorType::TokenMetadataNotAnObject { + issuer_id: issuer_id.to_string(), + }, + })?; + + // Convert to owned map to avoid cloning during iteration + let metadata_map: serde_json::Map = metadata_obj.clone(); + let mut token_metadata = HashMap::with_capacity(metadata_map.len()); + + for (token_type, token_config) in metadata_map { + // Validate that token config is an object + if !token_config.is_object() { + return Err(PolicyStoreError::TrustedIssuerError { + file: filename.to_string(), + err: TrustedIssuerErrorType::TokenMetadataEntryNotAnObject { + issuer_id: issuer_id.to_string(), + token_type: token_type.clone(), + }, + }); + } + + // Deserialize the TokenEntityMetadata + let metadata: TokenEntityMetadata = + serde_json::from_value(token_config).map_err(|e| { + PolicyStoreError::TrustedIssuerError { + file: filename.to_string(), + err: TrustedIssuerErrorType::MissingRequiredField { + issuer_id: issuer_id.to_string(), + field: format!("token_metadata.{}: {}", token_type, e), + }, + } + })?; + + // Validate required field: entity_type_name + if metadata.entity_type_name.is_empty() { + return Err(PolicyStoreError::TrustedIssuerError { + file: filename.to_string(), + err: TrustedIssuerErrorType::MissingRequiredField { + issuer_id: issuer_id.to_string(), + field: format!("token_metadata.{}.entity_type_name", token_type), + }, + }); + } + + token_metadata.insert(token_type, metadata); + } + + Ok(token_metadata) + } + + /// Validate a collection of parsed issuers for conflicts and completeness. + pub fn validate_issuers(issuers: &[ParsedIssuer]) -> Result<(), Vec> { + let mut errors = Vec::new(); + let mut seen_ids = HashMap::with_capacity(issuers.len()); + + for parsed in issuers { + // Check for duplicate issuer IDs (only insert if not duplicate) + if let Some(existing_file) = seen_ids.get(&parsed.id) { + errors.push(format!( + "Duplicate issuer ID '{}' found in files '{}' and '{}'", + parsed.id, existing_file, parsed.filename + )); + // Don't insert the duplicate - keep the first occurrence + } else { + seen_ids.insert(parsed.id.clone(), parsed.filename.clone()); + } + + // Token metadata is optional for JWKS-only configurations + // It's only required when token_metadata entries specify entity_type_name or required_claims + // for signed-token/trusted-issuer validation. Since we can't determine this requirement + // when token_metadata is empty, we allow empty token_metadata to support JWKS-only use cases. + // Validation of required fields within token_metadata entries is handled in parse_token_metadata. + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } + + /// Create a consolidated map of all issuers. + pub fn create_issuer_map( + issuers: Vec, + ) -> Result, PolicyStoreError> { + let mut issuer_map = HashMap::with_capacity(issuers.len()); + + for parsed in issuers { + // Check for duplicates (shouldn't happen if validate_issuers was called) + // Note: This is a defensive check - duplicates should be caught earlier + if let std::collections::hash_map::Entry::Vacant(e) = + issuer_map.entry(parsed.id.clone()) + { + e.insert(parsed.issuer); + } else { + // Skip duplicate silently since validate_issuers should have reported it + continue; + } + } + + Ok(issuer_map) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_issuer() { + let content = r#"{ + "test_issuer": { + "name": "Test Issuer", + "description": "A test OpenID Connect provider", + "openid_configuration_endpoint": "https://accounts.test.com/.well-known/openid-configuration" + } + }"#; + + let result = IssuerParser::parse_issuer(content, "issuer1.json"); + assert!(result.is_ok(), "Should parse simple issuer"); + + let parsed = result.unwrap(); + assert_eq!(parsed.len(), 1, "Should have 1 issuer"); + assert_eq!(parsed[0].id, "test_issuer"); + assert_eq!(parsed[0].issuer.name, "Test Issuer"); + assert_eq!( + parsed[0].issuer.description, + "A test OpenID Connect provider" + ); + assert_eq!( + parsed[0].issuer.oidc_endpoint.as_str(), + "https://accounts.test.com/.well-known/openid-configuration" + ); + } + + #[test] + fn test_parse_issuer_with_token_metadata() { + let content = r#"{ + "jans_issuer": { + "name": "Jans Server", + "description": "Jans OpenID Connect Provider", + "openid_configuration_endpoint": "https://jans.test/.well-known/openid-configuration", + "token_metadata": { + "access_token": { + "trusted": true, + "entity_type_name": "Jans::access_token", + "user_id": "sub", + "role_mapping": "role" + }, + "id_token": { + "trusted": true, + "entity_type_name": "Jans::id_token", + "user_id": "sub" + } + } + } + }"#; + + let result = IssuerParser::parse_issuer(content, "jans.json"); + assert!(result.is_ok(), "Should parse issuer with token metadata"); + + let parsed = result.unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].issuer.token_metadata.len(), 2); + + let access_token = parsed[0].issuer.token_metadata.get("access_token").unwrap(); + assert_eq!(access_token.entity_type_name, "Jans::access_token"); + assert_eq!(access_token.user_id, Some("sub".to_string())); + } + + #[test] + fn test_parse_multiple_issuers() { + let content = r#"{ + "issuer1": { + "name": "Issuer One", + "description": "First issuer", + "openid_configuration_endpoint": "https://issuer1.com/.well-known/openid-configuration" + }, + "issuer2": { + "name": "Issuer Two", + "description": "Second issuer", + "openid_configuration_endpoint": "https://issuer2.com/.well-known/openid-configuration" + } + }"#; + + let result = IssuerParser::parse_issuer(content, "issuers.json"); + assert!(result.is_ok(), "Should parse multiple issuers"); + + let parsed = result.unwrap(); + assert_eq!(parsed.len(), 2, "Should have 2 issuers"); + } + + #[test] + fn test_parse_issuer_missing_name() { + let content = r#"{ + "bad_issuer": { + "description": "Missing name field", + "openid_configuration_endpoint": "https://test.com/.well-known/openid-configuration" + } + }"#; + + let result = IssuerParser::parse_issuer(content, "bad.json"); + let err = result.expect_err("Should fail on missing name"); + + assert!( + matches!( + &err, + PolicyStoreError::TrustedIssuerError { + file, + err: TrustedIssuerErrorType::MissingRequiredField { issuer_id, field } + } if file == "bad.json" && issuer_id == "bad_issuer" && field == "name" + ), + "Expected MissingRequiredField error for name, got: {:?}", + err + ); + } + + #[test] + fn test_parse_issuer_missing_endpoint() { + let content = r#"{ + "bad_issuer": { + "name": "Test", + "description": "Missing endpoint" + } + }"#; + + let result = IssuerParser::parse_issuer(content, "bad.json"); + let err = result.expect_err("Should fail on missing endpoint"); + + assert!( + matches!( + &err, + PolicyStoreError::TrustedIssuerError { + file, + err: TrustedIssuerErrorType::MissingRequiredField { issuer_id, field } + } if file == "bad.json" && issuer_id == "bad_issuer" && field == "openid_configuration_endpoint" + ), + "Expected MissingRequiredField error for endpoint, got: {:?}", + err + ); + } + + #[test] + fn test_parse_issuer_invalid_url() { + let content = r#"{ + "bad_issuer": { + "name": "Test", + "description": "Invalid URL", + "openid_configuration_endpoint": "not a valid url" + } + }"#; + + let result = IssuerParser::parse_issuer(content, "bad.json"); + let err = result.expect_err("Should fail on invalid URL"); + + assert!( + matches!( + &err, + PolicyStoreError::TrustedIssuerError { + file, + err: TrustedIssuerErrorType::InvalidOidcEndpoint { issuer_id, url, .. } + } if file == "bad.json" && issuer_id == "bad_issuer" && url == "not a valid url" + ), + "Expected InvalidOidcEndpoint error, got: {:?}", + err + ); + } + + #[test] + fn test_parse_issuer_invalid_json() { + let content = "{ invalid json }"; + + let result = IssuerParser::parse_issuer(content, "invalid.json"); + let err = result.expect_err("Should fail on invalid JSON"); + + assert!( + matches!(&err, PolicyStoreError::JsonParsing { file, .. } if file == "invalid.json"), + "Expected JsonParsing error, got: {:?}", + err + ); + } + + #[test] + fn test_parse_token_metadata_missing_entity_type() { + let content = r#"{ + "issuer1": { + "name": "Test", + "description": "Test", + "openid_configuration_endpoint": "https://test.com/.well-known/openid-configuration", + "token_metadata": { + "access_token": { + "trusted": true + } + } + } + }"#; + + let result = IssuerParser::parse_issuer(content, "bad.json"); + let err = result.expect_err("Should fail on missing entity_type_name in token metadata"); + assert!( + matches!(&err, PolicyStoreError::TrustedIssuerError { .. }), + "Expected TrustedIssuerError, got: {:?}", + err + ); + } + + #[test] + fn test_validate_issuers_no_duplicates() { + let issuers = vec![ + ParsedIssuer { + id: "issuer1".to_string(), + issuer: TrustedIssuer { + name: "Issuer 1".to_string(), + description: "First".to_string(), + oidc_endpoint: Url::parse( + "https://issuer1.com/.well-known/openid-configuration", + ) + .unwrap(), + token_metadata: HashMap::from([( + "access_token".to_string(), + TokenEntityMetadata::access_token(), + )]), + }, + filename: "file1.json".to_string(), + }, + ParsedIssuer { + id: "issuer2".to_string(), + issuer: TrustedIssuer { + name: "Issuer 2".to_string(), + description: "Second".to_string(), + oidc_endpoint: Url::parse( + "https://issuer2.com/.well-known/openid-configuration", + ) + .unwrap(), + token_metadata: HashMap::from([( + "id_token".to_string(), + TokenEntityMetadata::id_token(), + )]), + }, + filename: "file2.json".to_string(), + }, + ]; + + let result = IssuerParser::validate_issuers(&issuers); + assert!(result.is_ok(), "Should have no validation errors"); + } + + #[test] + fn test_validate_issuers_duplicate_ids() { + let issuers = vec![ + ParsedIssuer { + id: "issuer1".to_string(), + issuer: TrustedIssuer { + name: "Issuer 1".to_string(), + description: "First".to_string(), + oidc_endpoint: Url::parse( + "https://issuer1.com/.well-known/openid-configuration", + ) + .unwrap(), + token_metadata: HashMap::from([( + "access_token".to_string(), + TokenEntityMetadata::access_token(), + )]), + }, + filename: "file1.json".to_string(), + }, + ParsedIssuer { + id: "issuer1".to_string(), + issuer: TrustedIssuer { + name: "Issuer 1 Duplicate".to_string(), + description: "Duplicate".to_string(), + oidc_endpoint: Url::parse( + "https://issuer1.com/.well-known/openid-configuration", + ) + .unwrap(), + token_metadata: HashMap::from([( + "id_token".to_string(), + TokenEntityMetadata::id_token(), + )]), + }, + filename: "file2.json".to_string(), + }, + ]; + + let result = IssuerParser::validate_issuers(&issuers); + let errors = result.expect_err("Should detect duplicate issuer IDs"); + + assert_eq!(errors.len(), 1, "Expected exactly one duplicate error"); + assert!( + errors[0].contains("issuer1") + && errors[0].contains("file1.json") + && errors[0].contains("file2.json"), + "Error should reference issuer1, file1.json and file2.json, got: {}", + errors[0] + ); + } + + #[test] + fn test_validate_issuers_no_token_metadata() { + let issuers = vec![ParsedIssuer { + id: "issuer1".to_string(), + issuer: TrustedIssuer { + name: "Issuer 1".to_string(), + description: "No tokens".to_string(), + oidc_endpoint: Url::parse("https://issuer1.com/.well-known/openid-configuration") + .unwrap(), + token_metadata: HashMap::new(), + }, + filename: "file1.json".to_string(), + }]; + + // Empty token_metadata is allowed for JWKS-only configurations + let result = IssuerParser::validate_issuers(&issuers); + result.expect("Should accept issuer with empty token_metadata for JWKS-only use case"); + } + + #[test] + fn test_create_issuer_map() { + let issuers = vec![ + ParsedIssuer { + id: "issuer1".to_string(), + issuer: TrustedIssuer { + name: "Issuer 1".to_string(), + description: "First".to_string(), + oidc_endpoint: Url::parse( + "https://issuer1.com/.well-known/openid-configuration", + ) + .unwrap(), + token_metadata: HashMap::from([( + "access_token".to_string(), + TokenEntityMetadata::access_token(), + )]), + }, + filename: "file1.json".to_string(), + }, + ParsedIssuer { + id: "issuer2".to_string(), + issuer: TrustedIssuer { + name: "Issuer 2".to_string(), + description: "Second".to_string(), + oidc_endpoint: Url::parse( + "https://issuer2.com/.well-known/openid-configuration", + ) + .unwrap(), + token_metadata: HashMap::from([( + "id_token".to_string(), + TokenEntityMetadata::id_token(), + )]), + }, + filename: "file2.json".to_string(), + }, + ]; + + let result = IssuerParser::create_issuer_map(issuers); + assert!(result.is_ok(), "Should create issuer map"); + + let map = result.unwrap(); + assert_eq!(map.len(), 2); + assert!(map.contains_key("issuer1")); + assert!(map.contains_key("issuer2")); + } +} diff --git a/jans-cedarling/cedarling/src/common/policy_store/loader.rs b/jans-cedarling/cedarling/src/common/policy_store/loader.rs new file mode 100644 index 00000000000..d6945240b0f --- /dev/null +++ b/jans-cedarling/cedarling/src/common/policy_store/loader.rs @@ -0,0 +1,708 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Policy store loader with format detection and directory loading support. +//! +//! # Internal API Note +//! +//! This module is part of the internal implementation. External users should use the +//! `Cedarling` API with `BootstrapConfig` to load policy stores. +//! +//! # Loading Archives (.cjar files) +//! +//! Archives are loaded using `ArchiveVfs`, which implements the `VfsFileSystem` trait. +//! This design: +//! - Works in WASM (no temp file extraction needed) +//! - Is efficient (reads files on-demand from archive) +//! - Is secure (no temp file cleanup concerns) + +use std::path::Path; + +use super::errors::{PolicyStoreError, ValidationError}; +use super::metadata::{PolicyStoreManifest, PolicyStoreMetadata}; +use super::validator::MetadataValidator; +use super::vfs_adapter::VfsFileSystem; + +/// Load a policy store from a directory path. +/// +/// This function uses `PhysicalVfs` to read from the local filesystem. +/// It is only available on native platforms (not WASM). +#[cfg(not(target_arch = "wasm32"))] +pub async fn load_policy_store_directory( + path: &Path, +) -> Result { + let path_str = path + .to_str() + .ok_or_else(|| PolicyStoreError::PathNotFound { + path: path.display().to_string(), + })? + .to_string(); + + // Offload blocking I/O operations to a blocking thread pool to avoid blocking the async runtime. + // `load_directory` is intentionally synchronous because it performs blocking filesystem I/O. + // Using `spawn_blocking` ensures these operations don't block the async executor. + tokio::task::spawn_blocking(move || { + // Use the PhysicalVfs-specific loader for directory-based stores. + let loader = DefaultPolicyStoreLoader::new_physical(); + + // Load all components from the directory. + let loaded = loader.load_directory(&path_str)?; + + // If a manifest is present, validate it against the physical filesystem. + if let Some(ref manifest) = loaded.manifest { + loader.validate_manifest(&path_str, &loaded.metadata, manifest)?; + } + + Ok(loaded) + }) + .await + .map_err(|e| { + // If the blocking task panicked, convert to an IO error. + // This should be rare and typically indicates a bug in the loader code. + PolicyStoreError::Io(std::io::Error::other(format!( + "Blocking task panicked: {}", + e + ))) + })? +} + +/// Load a policy store from a directory path (WASM stub). +/// +/// Directory loading is not supported in WASM environments. +/// Use `load_policy_store_archive_bytes` instead. +#[cfg(target_arch = "wasm32")] +pub async fn load_policy_store_directory( + _path: &Path, +) -> Result { + Err(super::errors::ArchiveError::WasmUnsupported.into()) +} + +/// Load a policy store from a Cedar Archive (.cjar) file. +/// +/// This function uses `ArchiveVfs` to read from a zip archive. +/// It is only available on native platforms (not WASM). +#[cfg(not(target_arch = "wasm32"))] +pub async fn load_policy_store_archive(path: &Path) -> Result { + let path = path.to_path_buf(); + + // Offload blocking I/O operations to a blocking thread pool to avoid blocking the async runtime. + // `load_directory` is intentionally synchronous because it performs blocking filesystem I/O + // (reading from zip archive). Using `spawn_blocking` ensures these operations don't block + // the async executor. + tokio::task::spawn_blocking(move || { + use super::archive_handler::ArchiveVfs; + let archive_vfs = ArchiveVfs::from_file(&path)?; + let loader = DefaultPolicyStoreLoader::new(archive_vfs); + loader.load_directory(".") + }) + .await + .map_err(|e| { + // If the blocking task panicked, convert to an IO error. + // This should be rare and typically indicates a bug in the loader code. + PolicyStoreError::Io(std::io::Error::other(format!( + "Blocking task panicked: {}", + e + ))) + })? +} + +/// Load a policy store from a Cedar Archive (.cjar) file (WASM stub). +/// +/// File-based archive loading is not supported in WASM environments. +/// Use `load_policy_store_archive_bytes` instead. +#[cfg(target_arch = "wasm32")] +pub async fn load_policy_store_archive( + _path: &Path, +) -> Result { + Err(super::errors::ArchiveError::WasmUnsupported.into()) +} + +/// Load a policy store from archive bytes. +/// +/// This function is useful for: +/// - WASM environments where file system access is not available +/// - Loading archives fetched from URLs +/// - Loading archives from any byte source +pub fn load_policy_store_archive_bytes( + bytes: Vec, +) -> Result { + use super::archive_handler::ArchiveVfs; + + let archive_vfs = ArchiveVfs::from_buffer(bytes.clone())?; + let loader = DefaultPolicyStoreLoader::new(archive_vfs); + let loaded = loader.load_directory(".")?; + + // Validate manifest if present (same validation used for archive-backed loading) + #[cfg(not(target_arch = "wasm32"))] + if let Some(ref _manifest) = loaded.manifest { + use super::manifest_validator::ManifestValidator; + use std::path::PathBuf; + + // Create a new ArchiveVfs instance for validation (ManifestValidator needs its own VFS) + let validator_vfs = ArchiveVfs::from_buffer(bytes)?; + let validator = ManifestValidator::new(validator_vfs, PathBuf::from(".")); + let result = validator.validate(Some(&loaded.metadata.policy_store.id)); + + // If validation fails, return the first error + if !result.is_valid + && let Some(error) = result.errors.first() + { + return Err(PolicyStoreError::ManifestError { + err: error.error_type.clone(), + }); + } + } + + Ok(loaded) +} + +/// A loaded policy store with all its components. +#[derive(Debug)] +pub struct LoadedPolicyStore { + /// Policy store metadata + pub metadata: PolicyStoreMetadata, + /// Optional manifest for integrity checking + pub manifest: Option, + /// Raw schema content + pub schema: String, + /// Policy files content (filename -> content) + pub policies: Vec, + /// Template files content (filename -> content) + pub templates: Vec, + /// Entity files content (filename -> content) + pub entities: Vec, + /// Trusted issuer files content (filename -> content) + pub trusted_issuers: Vec, +} + +/// A policy or template file. +#[derive(Debug, Clone)] +pub struct PolicyFile { + /// File name + pub name: String, + /// File content + pub content: String, +} + +/// An entity definition file. +#[derive(Debug, Clone)] +pub struct EntityFile { + /// File name + pub name: String, + /// JSON content + pub content: String, +} + +/// A trusted issuer configuration file. +#[derive(Debug, Clone)] +pub struct IssuerFile { + /// File name + pub name: String, + /// JSON content + pub content: String, +} + +/// Default implementation of policy store loader. +/// +/// Generic over a VFS implementation to support different storage backends: +/// - Physical filesystem for native platforms +/// - Memory filesystem for testing and WASM +/// - Archive filesystem for .cjar files +pub struct DefaultPolicyStoreLoader { + vfs: V, +} + +impl DefaultPolicyStoreLoader { + /// Create a new policy store loader with the given VFS backend. + pub fn new(vfs: V) -> Self { + Self { vfs } + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl DefaultPolicyStoreLoader { + /// Create a new policy store loader using the physical filesystem. + /// + /// This is a convenience constructor for native platforms. + pub fn new_physical() -> Self { + Self::new(super::vfs_adapter::PhysicalVfs::new()) + } + + /// Validate the manifest file against the policy store contents. + /// + /// This method is only available for PhysicalVfs because: + /// - It requires creating a new VFS instance for validation + /// - Other VFS types (MemoryVfs, custom implementations) may not support cheap instantiation + /// - WASM environments may not have filesystem access for validation + /// + /// Users of other VFS types should call ManifestValidator::validate() directly + /// with their VFS instance if they need manifest validation. + /// + /// This method is public so it can be called explicitly when needed, following + /// the Interface Segregation Principle. + #[cfg(not(target_arch = "wasm32"))] + pub fn validate_manifest( + &self, + dir: &str, + metadata: &PolicyStoreMetadata, + _manifest: &PolicyStoreManifest, + ) -> Result<(), PolicyStoreError> { + self.validate_manifest_with_logger(dir, metadata, _manifest, None) + } + + /// Validate the manifest file with optional logging for unlisted files. + /// + /// Same as `validate_manifest` but accepts an optional logger for structured logging. + #[cfg(not(target_arch = "wasm32"))] + pub fn validate_manifest_with_logger( + &self, + dir: &str, + metadata: &PolicyStoreMetadata, + _manifest: &PolicyStoreManifest, + logger: Option, + ) -> Result<(), PolicyStoreError> { + use super::log_entry::PolicyStoreLogEntry; + use super::manifest_validator::ManifestValidator; + use crate::log::interface::LogWriter; + use std::path::PathBuf; + + // Create a new PhysicalVfs instance for validation + let validator = + ManifestValidator::new(super::vfs_adapter::PhysicalVfs::new(), PathBuf::from(dir)); + + let result = validator.validate(Some(&metadata.policy_store.id)); + + // If validation fails, return the first error + if !result.is_valid + && let Some(error) = result.errors.first() + { + return Err(PolicyStoreError::ManifestError { + err: error.error_type.clone(), + }); + } + + // Log unlisted files if any (informational - these files are allowed but not checksummed) + if !result.unlisted_files.is_empty() + && let Some(logger) = logger + { + logger.log_any(PolicyStoreLogEntry::info(format!( + "Policy store contains {} unlisted file(s) not in manifest: {:?}", + result.unlisted_files.len(), + result.unlisted_files + ))); + } + + Ok(()) + } +} + +impl DefaultPolicyStoreLoader { + /// Helper to join paths, handling "." correctly. + fn join_path(base: &str, file: &str) -> String { + if base == "." || base.is_empty() { + file.to_string() + } else { + format!("{}/{}", base, file) + } + } + + /// Validate directory structure for required files and directories. + fn validate_directory_structure(&self, dir: &str) -> Result<(), PolicyStoreError> { + // Check if directory exists + if !self.vfs.exists(dir) { + return Err(PolicyStoreError::PathNotFound { + path: dir.to_string(), + }); + } + + if !self.vfs.is_dir(dir) { + return Err(PolicyStoreError::NotADirectory { + path: dir.to_string(), + }); + } + + // Ensure is_file method is used (prevents dead code warning in WASM) + // This is a no-op check that ensures the trait method is called + let _ = self.vfs.is_file(dir); + + // Check for required files + let metadata_path = Self::join_path(dir, "metadata.json"); + if !self.vfs.exists(&metadata_path) { + return Err(ValidationError::MissingRequiredFile { + file: "metadata.json".to_string(), + } + .into()); + } + + let schema_path = Self::join_path(dir, "schema.cedarschema"); + if !self.vfs.exists(&schema_path) { + return Err(ValidationError::MissingRequiredFile { + file: "schema.cedarschema".to_string(), + } + .into()); + } + + // Check for required directories + let policies_dir = Self::join_path(dir, "policies"); + if !self.vfs.exists(&policies_dir) { + return Err(ValidationError::MissingRequiredDirectory { + directory: "policies".to_string(), + } + .into()); + } + + if !self.vfs.is_dir(&policies_dir) { + return Err(PolicyStoreError::NotADirectory { + path: policies_dir.clone(), + }); + } + + Ok(()) + } + + /// Load metadata from metadata.json file. + fn load_metadata(&self, dir: &str) -> Result { + let metadata_path = Self::join_path(dir, "metadata.json"); + let bytes = self.vfs.read_file(&metadata_path).map_err(|source| { + PolicyStoreError::FileReadError { + path: metadata_path.clone(), + source, + } + })?; + + let content = String::from_utf8(bytes).map_err(|e| PolicyStoreError::FileReadError { + path: metadata_path.clone(), + source: std::io::Error::new(std::io::ErrorKind::InvalidData, e), + })?; + + // Parse and validate metadata + MetadataValidator::parse_and_validate(&content).map_err(PolicyStoreError::Validation) + } + + /// Load optional manifest from manifest.json file. + fn load_manifest(&self, dir: &str) -> Result, PolicyStoreError> { + let manifest_path = Self::join_path(dir, "manifest.json"); + if !self.vfs.exists(&manifest_path) { + return Ok(None); + } + + // Open file and parse JSON using from_reader for better performance + let reader = self.vfs.open_file(&manifest_path).map_err(|source| { + PolicyStoreError::FileReadError { + path: manifest_path.clone(), + source, + } + })?; + + let manifest = + serde_json::from_reader(reader).map_err(|source| PolicyStoreError::JsonParsing { + file: "manifest.json".to_string(), + source, + })?; + + Ok(Some(manifest)) + } + + /// Load schema from schema.cedarschema file. + fn load_schema(&self, dir: &str) -> Result { + let schema_path = Self::join_path(dir, "schema.cedarschema"); + let bytes = + self.vfs + .read_file(&schema_path) + .map_err(|source| PolicyStoreError::FileReadError { + path: schema_path.clone(), + source, + })?; + + String::from_utf8(bytes).map_err(|e| PolicyStoreError::FileReadError { + path: schema_path.clone(), + source: std::io::Error::new(std::io::ErrorKind::InvalidData, e), + }) + } + + /// Load all policy files from policies directory. + fn load_policies(&self, dir: &str) -> Result, PolicyStoreError> { + let policies_dir = Self::join_path(dir, "policies"); + self.load_cedar_files(&policies_dir, "policy") + } + + /// Load all template files from templates directory (if exists). + fn load_templates(&self, dir: &str) -> Result, PolicyStoreError> { + let templates_dir = Self::join_path(dir, "templates"); + if !self.vfs.exists(&templates_dir) { + return Ok(Vec::new()); + } + + self.load_cedar_files(&templates_dir, "template") + } + + /// Load all entity files from entities directory (if exists). + fn load_entities(&self, dir: &str) -> Result, PolicyStoreError> { + let entities_dir = Self::join_path(dir, "entities"); + if !self.vfs.exists(&entities_dir) { + return Ok(Vec::new()); + } + + self.load_json_files(&entities_dir, "entity") + } + + /// Load all trusted issuer files from trusted-issuers directory (if exists). + fn load_trusted_issuers(&self, dir: &str) -> Result, PolicyStoreError> { + let issuers_dir = Self::join_path(dir, "trusted-issuers"); + if !self.vfs.exists(&issuers_dir) { + return Ok(Vec::new()); + } + + let entries = self.vfs.read_dir(&issuers_dir).map_err(|source| { + PolicyStoreError::DirectoryReadError { + path: issuers_dir.clone(), + source, + } + })?; + + let mut issuers = Vec::new(); + for entry in entries { + if !entry.is_dir { + // Validate .json extension + if !entry.name.to_lowercase().ends_with(".json") { + return Err(ValidationError::InvalidFileExtension { + file: entry.path.clone(), + expected: ".json".to_string(), + actual: Path::new(&entry.name) + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("(none)") + .to_string(), + } + .into()); + } + + let bytes = self.vfs.read_file(&entry.path).map_err(|source| { + PolicyStoreError::FileReadError { + path: entry.path.clone(), + source, + } + })?; + + let content = + String::from_utf8(bytes).map_err(|e| PolicyStoreError::FileReadError { + path: entry.path.clone(), + source: std::io::Error::new(std::io::ErrorKind::InvalidData, e), + })?; + + issuers.push(IssuerFile { + name: entry.name, + content, + }); + } + } + + Ok(issuers) + } + + /// Helper: Load all .cedar files from a directory, recursively scanning subdirectories. + fn load_cedar_files( + &self, + dir: &str, + _file_type: &str, + ) -> Result, PolicyStoreError> { + let mut files = Vec::new(); + self.load_cedar_files_recursive(dir, &mut files)?; + Ok(files) + } + + /// Recursive helper to load .cedar files from a directory and its subdirectories. + fn load_cedar_files_recursive( + &self, + dir: &str, + files: &mut Vec, + ) -> Result<(), PolicyStoreError> { + let entries = + self.vfs + .read_dir(dir) + .map_err(|source| PolicyStoreError::DirectoryReadError { + path: dir.to_string(), + source, + })?; + + for entry in entries { + if entry.is_dir { + // Recursively scan subdirectories + self.load_cedar_files_recursive(&entry.path, files)?; + } else { + // Validate .cedar extension + if !entry.name.to_lowercase().ends_with(".cedar") { + return Err(ValidationError::InvalidFileExtension { + file: entry.path.clone(), + expected: ".cedar".to_string(), + actual: Path::new(&entry.name) + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("(none)") + .to_string(), + } + .into()); + } + + let bytes = self.vfs.read_file(&entry.path).map_err(|source| { + PolicyStoreError::FileReadError { + path: entry.path.clone(), + source, + } + })?; + + let content = + String::from_utf8(bytes).map_err(|e| PolicyStoreError::FileReadError { + path: entry.path.clone(), + source: std::io::Error::new(std::io::ErrorKind::InvalidData, e), + })?; + + files.push(PolicyFile { + name: entry.name, + content, + }); + } + } + + Ok(()) + } + + /// Helper: Load all .json files from a directory. + fn load_json_files( + &self, + dir: &str, + _file_type: &str, + ) -> Result, PolicyStoreError> { + let entries = + self.vfs + .read_dir(dir) + .map_err(|source| PolicyStoreError::DirectoryReadError { + path: dir.to_string(), + source, + })?; + + let mut files = Vec::new(); + for entry in entries { + if !entry.is_dir { + // Validate .json extension + if !entry.name.to_lowercase().ends_with(".json") { + return Err(ValidationError::InvalidFileExtension { + file: entry.path.clone(), + expected: ".json".to_string(), + actual: Path::new(&entry.name) + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("(none)") + .to_string(), + } + .into()); + } + + let bytes = self.vfs.read_file(&entry.path).map_err(|source| { + PolicyStoreError::FileReadError { + path: entry.path.clone(), + source, + } + })?; + + let content = + String::from_utf8(bytes).map_err(|e| PolicyStoreError::FileReadError { + path: entry.path.clone(), + source: std::io::Error::new(std::io::ErrorKind::InvalidData, e), + })?; + + files.push(EntityFile { + name: entry.name, + content, + }); + } + } + + Ok(files) + } + + /// Load a directory-based policy store. + /// + /// This method is generic over the underlying `VfsFileSystem` and **does not** + /// perform manifest validation. For backends that need manifest validation + /// (e.g., `PhysicalVfs`), callers should use higher-level helpers such as + /// `load_policy_store_directory` or call `validate_manifest` explicitly on + /// `DefaultPolicyStoreLoader`. + pub fn load_directory(&self, dir: &str) -> Result { + // Validate structure first + self.validate_directory_structure(dir)?; + + // Load all components + let metadata = self.load_metadata(dir)?; + let manifest = self.load_manifest(dir)?; + + let schema = self.load_schema(dir)?; + let policies = self.load_policies(dir)?; + let templates = self.load_templates(dir)?; + let entities = self.load_entities(dir)?; + let trusted_issuers = self.load_trusted_issuers(dir)?; + + Ok(LoadedPolicyStore { + metadata, + manifest, + schema, + policies, + templates, + entities, + trusted_issuers, + }) + } +} + +// Test-only helper functions for parsing policies +// These are thin wrappers around PolicyParser for test convenience +#[cfg(test)] +use super::policy_parser; + +#[cfg(test)] +impl DefaultPolicyStoreLoader { + /// Parse and validate Cedar policies from loaded policy files. + fn parse_policies( + policy_files: &[PolicyFile], + ) -> Result, PolicyStoreError> { + let mut parsed_policies = Vec::with_capacity(policy_files.len()); + for file in policy_files { + let parsed = policy_parser::PolicyParser::parse_policy(&file.content, &file.name)?; + parsed_policies.push(parsed); + } + Ok(parsed_policies) + } + + /// Parse and validate Cedar templates from loaded template files. + fn parse_templates( + template_files: &[PolicyFile], + ) -> Result, PolicyStoreError> { + let mut parsed_templates = Vec::with_capacity(template_files.len()); + for file in template_files { + let parsed = policy_parser::PolicyParser::parse_template(&file.content, &file.name)?; + parsed_templates.push(parsed); + } + Ok(parsed_templates) + } + + /// Create a Cedar PolicySet from parsed policies and templates. + fn create_policy_set( + policies: Vec, + templates: Vec, + ) -> Result { + policy_parser::PolicyParser::create_policy_set(policies, templates) + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl Default for DefaultPolicyStoreLoader { + fn default() -> Self { + Self::new_physical() + } +} + +#[cfg(test)] +#[path = "loader_tests.rs"] +mod tests; diff --git a/jans-cedarling/cedarling/src/common/policy_store/loader_tests.rs b/jans-cedarling/cedarling/src/common/policy_store/loader_tests.rs new file mode 100644 index 00000000000..24ea0fcb93e --- /dev/null +++ b/jans-cedarling/cedarling/src/common/policy_store/loader_tests.rs @@ -0,0 +1,1473 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Tests for the policy store loader module. +//! +//! This module is extracted from `loader.rs` for maintainability. + +use super::super::archive_handler::ArchiveVfs; +use super::super::entity_parser::EntityParser; +use super::super::errors::{CedarParseErrorDetail, PolicyStoreError, ValidationError}; +use super::super::issuer_parser::IssuerParser; +#[cfg(not(target_arch = "wasm32"))] +use super::super::manifest_validator::ManifestValidator; +use super::super::schema_parser::ParsedSchema; +use super::super::vfs_adapter::{MemoryVfs, PhysicalVfs}; +use super::*; +use std::fs::{self, File}; +use std::io::{Cursor, Write}; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; +use zip::CompressionMethod; +use zip::write::{ExtendedFileOptions, FileOptions}; + +type PhysicalLoader = DefaultPolicyStoreLoader; + +/// Helper to create a minimal valid policy store directory for testing. +fn create_test_policy_store(dir: &Path) -> std::io::Result<()> { + // Create metadata.json + let metadata = r#"{ + "cedar_version": "4.4.0", + "policy_store": { + "id": "abc123def456", + "name": "Test Policy Store", + "version": "1.0.0" + } + }"#; + fs::write(dir.join("metadata.json"), metadata)?; + + // Create schema.cedarschema + let schema = r#" +namespace TestApp { +entity User; +entity Resource; +action "read" appliesTo { + principal: [User], + resource: [Resource] +}; +} +"#; + fs::write(dir.join("schema.cedarschema"), schema)?; + + // Create policies directory with a policy + fs::create_dir(dir.join("policies"))?; + let policy = r#"@id("test-policy") +permit( +principal == TestApp::User::"alice", +action == TestApp::Action::"read", +resource == TestApp::Resource::"doc1" +);"#; + fs::write(dir.join("policies/test-policy.cedar"), policy)?; + + Ok(()) +} + +/// Helper to create a test archive in memory with standard structure. +fn create_test_archive( + name: &str, + id: &str, + extra_policies: &[(&str, &str)], + extra_entities: &[(&str, &str)], +) -> Vec { + let options = || { + FileOptions::::default() + .compression_method(CompressionMethod::Deflated) + }; + + let mut archive_bytes = Vec::new(); + { + let cursor = Cursor::new(&mut archive_bytes); + let mut zip = zip::ZipWriter::new(cursor); + + // Metadata + zip.start_file("metadata.json", options()).unwrap(); + write!( + zip, + r#"{{"cedar_version":"4.4.0","policy_store":{{"id":"{}","name":"{}","version":"1.0.0"}}}}"#, + id, name + ) + .unwrap(); + + // Schema + zip.start_file("schema.cedarschema", options()).unwrap(); + zip.write_all(b"namespace TestApp { entity User; entity Resource; }") + .unwrap(); + + // Default policy + zip.start_file("policies/default.cedar", options()).unwrap(); + zip.write_all(b"permit(principal, action, resource);") + .unwrap(); + + // Extra policies + for (policy_name, content) in extra_policies { + zip.start_file(format!("policies/{}", policy_name), options()) + .unwrap(); + zip.write_all(content.as_bytes()).unwrap(); + } + + // Extra entities + for (entity_name, content) in extra_entities { + zip.start_file(format!("entities/{}", entity_name), options()) + .unwrap(); + zip.write_all(content.as_bytes()).unwrap(); + } + + zip.finish().unwrap(); + } + archive_bytes +} + +#[test] +fn test_validate_nonexistent_directory() { + let loader = DefaultPolicyStoreLoader::new_physical(); + let path = PathBuf::from("/nonexistent/path"); + let path_str = path.to_str().unwrap_or("/nonexistent/path"); + let result = loader.validate_directory_structure(path_str); + let err = result.expect_err("Expected error for nonexistent directory"); + assert!( + matches!(&err, PolicyStoreError::PathNotFound { .. }) + || matches!(&err, PolicyStoreError::Io(_)), + "Expected PathNotFound or Io error, got: {:?}", + err + ); +} + +#[test] +fn test_validate_directory_missing_metadata() { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path(); + + // Create only schema, no metadata + fs::write(dir.join("schema.cedarschema"), "test").unwrap(); + fs::create_dir(dir.join("policies")).unwrap(); + + let loader = DefaultPolicyStoreLoader::new_physical(); + let result = loader.validate_directory_structure(dir.to_str().unwrap()); + + let err = result.expect_err("Expected error for missing metadata.json"); + assert!( + matches!( + &err, + PolicyStoreError::Validation(ValidationError::MissingRequiredFile { file }) + if file.contains("metadata") + ), + "Expected MissingRequiredFile error for metadata.json, got: {:?}", + err + ); +} + +#[test] +fn test_validate_directory_missing_schema() { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path(); + + // Create metadata but no schema + fs::write(dir.join("metadata.json"), "{}").unwrap(); + fs::create_dir(dir.join("policies")).unwrap(); + + let loader = DefaultPolicyStoreLoader::new_physical(); + let result = loader.validate_directory_structure(dir.to_str().unwrap()); + + let err = result.expect_err("Expected error for missing schema.cedarschema"); + assert!( + matches!( + &err, + PolicyStoreError::Validation(ValidationError::MissingRequiredFile { file }) + if file.contains("schema") + ), + "Expected MissingRequiredFile error for schema.cedarschema, got: {:?}", + err + ); +} + +#[test] +fn test_validate_directory_missing_policies_dir() { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path(); + + // Create files but no policies directory + fs::write(dir.join("metadata.json"), "{}").unwrap(); + fs::write(dir.join("schema.cedarschema"), "test").unwrap(); + + let loader = DefaultPolicyStoreLoader::new_physical(); + let result = loader.validate_directory_structure(dir.to_str().unwrap()); + + let err = result.expect_err("Expected error for missing policies directory"); + assert!( + matches!( + &err, + PolicyStoreError::Validation(ValidationError::MissingRequiredDirectory { directory }) + if directory.contains("policies") + ), + "Expected MissingRequiredDirectory error for policies, got: {:?}", + err + ); +} + +#[test] +fn test_validate_directory_success() { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path(); + + // Create valid structure + create_test_policy_store(dir).unwrap(); + + let loader = DefaultPolicyStoreLoader::new_physical(); + let result = loader.validate_directory_structure(dir.to_str().unwrap()); + + assert!(result.is_ok()); +} + +#[test] +fn test_load_directory_success() { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path(); + + // Create valid policy store + create_test_policy_store(dir).unwrap(); + + let loader = DefaultPolicyStoreLoader::new_physical(); + let loaded = loader + .load_directory(dir.to_str().unwrap()) + .expect("Expected directory load to succeed"); + + // Verify loaded data + assert_eq!(loaded.metadata.cedar_version, "4.4.0"); + assert_eq!(loaded.metadata.policy_store.name, "Test Policy Store"); + assert!(!loaded.schema.is_empty()); + assert_eq!(loaded.policies.len(), 1); + assert_eq!(loaded.policies[0].name, "test-policy.cedar"); +} + +#[test] +fn test_load_directory_with_optional_components() { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path(); + + // Create basic structure + create_test_policy_store(dir).unwrap(); + + // Add optional components + fs::create_dir(dir.join("templates")).unwrap(); + fs::write( + dir.join("templates/template1.cedar"), + "@id(\"template1\") permit(principal, action, resource);", + ) + .unwrap(); + + fs::create_dir(dir.join("entities")).unwrap(); + fs::write(dir.join("entities/users.json"), "[]").unwrap(); + + fs::create_dir(dir.join("trusted-issuers")).unwrap(); + fs::write(dir.join("trusted-issuers/issuer1.json"), "{}").unwrap(); + + let loader = DefaultPolicyStoreLoader::new_physical(); + let loaded = loader + .load_directory(dir.to_str().unwrap()) + .expect("Expected directory load with optional components to succeed"); + + assert_eq!(loaded.templates.len(), 1); + assert_eq!(loaded.entities.len(), 1); + assert_eq!(loaded.trusted_issuers.len(), 1); +} + +#[test] +fn test_load_directory_invalid_policy_extension() { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path(); + + create_test_policy_store(dir).unwrap(); + + // Add file with wrong extension + fs::write(dir.join("policies/bad.txt"), "invalid").unwrap(); + + let loader = DefaultPolicyStoreLoader::new_physical(); + let result = loader.load_directory(dir.to_str().unwrap()); + + let err = result.expect_err("Expected error for invalid policy file extension"); + assert!( + matches!( + &err, + PolicyStoreError::Validation(ValidationError::InvalidFileExtension { .. }) + ), + "Expected InvalidFileExtension error, got: {:?}", + err + ); +} + +#[test] +fn test_load_directory_invalid_json() { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path(); + + // Create invalid metadata + fs::write(dir.join("metadata.json"), "not valid json").unwrap(); + fs::write(dir.join("schema.cedarschema"), "schema").unwrap(); + fs::create_dir(dir.join("policies")).unwrap(); + + let loader = DefaultPolicyStoreLoader::new_physical(); + let result = loader.load_directory(dir.to_str().unwrap()); + + let err = result.expect_err("Expected error for invalid JSON in metadata.json"); + assert!( + matches!(&err, PolicyStoreError::JsonParsing { file, .. } if file.contains("metadata")) + || matches!( + &err, + PolicyStoreError::Validation(ValidationError::MetadataJsonParseFailed { .. }) + ), + "Expected JsonParsing or MetadataJsonParseFailed error, got: {:?}", + err + ); +} + +#[test] +fn test_parse_policies_success() { + let policy_files = vec![ + PolicyFile { + name: "policy1.cedar".to_string(), + content: r#"permit(principal, action, resource);"#.to_string(), + }, + PolicyFile { + name: "policy2.cedar".to_string(), + content: r#"forbid(principal, action, resource);"#.to_string(), + }, + ]; + let result = PhysicalLoader::parse_policies(&policy_files); + + let parsed = result.expect("failed to parse policies"); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].filename, "policy1.cedar"); + assert_eq!(parsed[0].id.to_string(), "policy1"); + assert_eq!(parsed[1].filename, "policy2.cedar"); + assert_eq!(parsed[1].id.to_string(), "policy2"); +} + +#[test] +fn test_parse_policies_with_id_annotation() { + let policy_files = vec![PolicyFile { + name: "my_policy.cedar".to_string(), + content: r#" + // @id("custom-id-123") + permit( + principal == User::"alice", + action == Action::"view", + resource == File::"doc.txt" + ); + "# + .to_string(), + }]; + + let result = PhysicalLoader::parse_policies(&policy_files); + let parsed = result.expect("failed to parse policies with id annotation"); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].id.to_string(), "custom-id-123"); +} + +#[test] +fn test_parse_policies_invalid_syntax() { + let policy_files = vec![PolicyFile { + name: "invalid.cedar".to_string(), + content: "this is not valid cedar syntax".to_string(), + }]; + + let result = PhysicalLoader::parse_policies(&policy_files); + let err = result.expect_err("Expected CedarParsing error for invalid syntax"); + + assert!( + matches!( + &err, + PolicyStoreError::CedarParsing { file, detail: CedarParseErrorDetail::ParseError(_) } + if file == "invalid.cedar" + ), + "Expected CedarParsing error with ParseError detail, got: {:?}", + err + ); +} + +#[test] +fn test_parse_templates_success() { + let template_files = vec![PolicyFile { + name: "template1.cedar".to_string(), + content: r#"permit(principal == ?principal, action, resource);"#.to_string(), + }]; + + let result = PhysicalLoader::parse_templates(&template_files); + let parsed = result.expect("failed to parse templates"); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].filename, "template1.cedar"); + assert_eq!(parsed[0].template.id().to_string(), "template1"); +} + +#[test] +fn test_create_policy_set_integration() { + let policy_files = vec![ + PolicyFile { + name: "allow.cedar".to_string(), + content: r#"permit(principal, action, resource);"#.to_string(), + }, + PolicyFile { + name: "deny.cedar".to_string(), + content: r#"forbid(principal, action, resource);"#.to_string(), + }, + ]; + + let template_files = vec![PolicyFile { + name: "user_template.cedar".to_string(), + content: r#"permit(principal == ?principal, action, resource);"#.to_string(), + }]; + + let policies = PhysicalLoader::parse_policies(&policy_files).unwrap(); + let templates = PhysicalLoader::parse_templates(&template_files).unwrap(); + + let result = PhysicalLoader::create_policy_set(policies, templates); + assert!(result.is_ok()); + + let policy_set = result.unwrap(); + assert!(!policy_set.is_empty()); +} + +#[test] +fn test_load_and_parse_policies_end_to_end() { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path(); + + // Create a complete policy store structure + create_test_policy_store(dir).expect("Failed to create test policy store"); + + // Add some Cedar policies + let policies_dir = dir.join("policies"); + fs::write( + policies_dir.join("view_policy.cedar"), + r#" + // @id("allow-view-docs") + permit( + principal == User::"alice", + action == Action::"view", + resource == File::"document.txt" + ); + "#, + ) + .unwrap(); + + fs::write( + policies_dir.join("edit_policy.cedar"), + r#" + permit( + principal == User::"bob", + action == Action::"edit", + resource == File::"document.txt" + ); + "#, + ) + .unwrap(); + + // Load the policy store + let loader = DefaultPolicyStoreLoader::new_physical(); + let loaded = loader + .load_directory(dir.to_str().unwrap()) + .expect("Expected directory load to succeed"); + + // Parse the policies + let parsed_policies = PhysicalLoader::parse_policies(&loaded.policies).unwrap(); + + // Should have 3 policies: 1 from create_test_policy_store helper + 2 from this test + assert_eq!(parsed_policies.len(), 3); + + // Check that policies have the expected IDs + let ids: Vec = parsed_policies.iter().map(|p| p.id.to_string()).collect(); + assert!(ids.contains(&"test-policy".to_string())); // From helper + assert!(ids.contains(&"allow-view-docs".to_string())); // Custom ID + assert!(ids.contains(&"edit_policy".to_string())); // Derived from filename + + // Create a policy set + let policy_set = PhysicalLoader::create_policy_set(parsed_policies, vec![]).unwrap(); + assert!(!policy_set.is_empty()); +} + +#[test] +fn test_load_and_parse_schema_end_to_end() { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path(); + + // Create a complete policy store structure + create_test_policy_store(dir).expect("Failed to create test policy store"); + + // Update schema with more complex content + let schema_content = r#" + namespace PhotoApp { + entity User = { + "username": String, + "email": String, + "roles": Set + }; + + entity Photo = { + "title": String, + "owner": User, + "public": Bool + }; + + entity Album = { + "name": String, + "photos": Set + }; + + action "view" appliesTo { + principal: [User], + resource: [Photo, Album], + context: { + "ip_address": String + } + }; + + action "edit" appliesTo { + principal: [User], + resource: [Photo, Album] + }; + + action "delete" appliesTo { + principal: [User], + resource: [Photo, Album] + }; + } + "#; + + fs::write(dir.join("schema.cedarschema"), schema_content).unwrap(); + + // Load the policy store + let loader = DefaultPolicyStoreLoader::new_physical(); + let loaded = loader + .load_directory(dir.to_str().unwrap()) + .expect("Expected directory load to succeed"); + + // Schema should be loaded + assert!(!loaded.schema.is_empty(), "Schema should not be empty"); + + // Parse the schema + let parsed = + ParsedSchema::parse(&loaded.schema, "schema.cedarschema").expect("Should parse schema"); + assert_eq!(parsed.filename, "schema.cedarschema"); + assert_eq!(parsed.content, schema_content); + + // Validate the schema + parsed.validate().expect("Schema should be valid"); + + // Get the Cedar schema object + let schema = parsed.get_schema(); + assert!(!format!("{:?}", schema).is_empty()); +} + +#[test] +fn test_load_and_parse_entities_end_to_end() { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path(); + + // Create a complete policy store structure + create_test_policy_store(dir).expect("Failed to create test policy store"); + + // Create entities directory with entity files + let entities_dir = dir.join("entities"); + fs::create_dir(&entities_dir).unwrap(); + + // Add entity files + fs::write( + entities_dir.join("users.json"), + r#"[ + { + "uid": {"type": "Jans::User", "id": "alice"}, + "attrs": { + "email": "alice@example.com", + "role": "admin" + }, + "parents": [] + }, + { + "uid": {"type": "Jans::User", "id": "bob"}, + "attrs": { + "email": "bob@example.com", + "role": "user" + }, + "parents": [] + } + ]"#, + ) + .unwrap(); + + fs::write( + entities_dir.join("roles.json"), + r#"{ + "admin": { + "uid": {"type": "Jans::Role", "id": "admin"}, + "attrs": { + "name": "Administrator" + }, + "parents": [] + } + }"#, + ) + .unwrap(); + + // Load the policy store + let loader = DefaultPolicyStoreLoader::new_physical(); + let loaded = loader + .load_directory(dir.to_str().unwrap()) + .expect("Expected directory load to succeed"); + + // Entities should be loaded + assert!(!loaded.entities.is_empty(), "Entities should be loaded"); + + // Parse entities from all files + let mut all_entities = Vec::new(); + + for entity_file in &loaded.entities { + let parsed_entities = + EntityParser::parse_entities(&entity_file.content, &entity_file.name, None) + .expect("Should parse entities"); + all_entities.extend(parsed_entities); + } + + // Should have 3 entities total (2 users + 1 role) + assert_eq!(all_entities.len(), 3, "Should have 3 entities total"); + + // Verify UIDs + let uids: Vec = all_entities.iter().map(|e| e.uid.to_string()).collect(); + assert!(uids.contains(&"Jans::User::\"alice\"".to_string())); + assert!(uids.contains(&"Jans::User::\"bob\"".to_string())); + assert!(uids.contains(&"Jans::Role::\"admin\"".to_string())); + + // Create entity store + let entity_store = EntityParser::create_entities_store(all_entities); + assert!(entity_store.is_ok(), "Should create entity store"); + assert_eq!( + entity_store.unwrap().iter().count(), + 3, + "Store should have 3 entities" + ); +} + +#[test] +fn test_entity_with_complex_attributes() { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path(); + + // Create a complete policy store structure + create_test_policy_store(dir).expect("Failed to create test policy store"); + + // Create entities directory with complex attributes + let entities_dir = dir.join("entities"); + fs::create_dir(&entities_dir).unwrap(); + + fs::write( + entities_dir.join("complex.json"), + r#"[ + { + "uid": {"type": "Jans::User", "id": "alice"}, + "attrs": { + "email": "alice@example.com", + "roles": ["admin", "developer"], + "metadata": { + "department": "Engineering", + "level": 5 + }, + "active": true + }, + "parents": [] + } + ]"#, + ) + .unwrap(); + + // Load the policy store + let loader = DefaultPolicyStoreLoader::new_physical(); + let loaded = loader + .load_directory(dir.to_str().unwrap()) + .expect("Expected directory load to succeed"); + + // Parse entities + let mut all_entities = Vec::new(); + + for entity_file in &loaded.entities { + let parsed_entities = + EntityParser::parse_entities(&entity_file.content, &entity_file.name, None) + .expect("Should parse entities with complex attributes"); + all_entities.extend(parsed_entities); + } + + assert_eq!(all_entities.len(), 1); + + // Verify attributes are preserved + let alice_json = all_entities[0].entity.to_json_value().unwrap(); + let attrs = alice_json.get("attrs").unwrap(); + + assert!(attrs.get("email").is_some()); + assert!(attrs.get("roles").is_some()); + assert!(attrs.get("metadata").is_some()); + assert!(attrs.get("active").is_some()); +} + +#[test] +fn test_load_and_parse_trusted_issuers_end_to_end() { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path(); + + // Create a complete policy store structure + create_test_policy_store(dir).expect("Failed to create test policy store"); + + // Create trusted-issuers directory with issuer files + let issuers_dir = dir.join("trusted-issuers"); + fs::create_dir(&issuers_dir).unwrap(); + + // Add issuer configuration + fs::write( + issuers_dir.join("jans.json"), + r#"{ + "jans_server": { + "name": "Jans Authorization Server", + "description": "Primary Jans OpenID Connect Provider", + "openid_configuration_endpoint": "https://jans.test/.well-known/openid-configuration", + "token_metadata": { + "access_token": { + "trusted": true, + "entity_type_name": "Jans::access_token", + "user_id": "sub", + "role_mapping": "role" + }, + "id_token": { + "trusted": true, + "entity_type_name": "Jans::id_token", + "user_id": "sub" + } + } + } + }"#, + ) + .unwrap(); + + fs::write( + issuers_dir.join("google.json"), + r#"{ + "google_oauth": { + "name": "Google OAuth", + "description": "Google OAuth 2.0 Provider", + "openid_configuration_endpoint": "https://accounts.google.com/.well-known/openid-configuration", + "token_metadata": { + "id_token": { + "trusted": false, + "entity_type_name": "Google::id_token", + "user_id": "email" + } + } + } + }"#, + ) + .unwrap(); + + // Load the policy store + let loader = DefaultPolicyStoreLoader::new_physical(); + let loaded = loader + .load_directory(dir.to_str().unwrap()) + .expect("Expected directory load to succeed"); + + // Issuers should be loaded + assert!( + !loaded.trusted_issuers.is_empty(), + "Issuers should be loaded" + ); + assert_eq!( + loaded.trusted_issuers.len(), + 2, + "Should have 2 issuer files" + ); + + // Parse issuers from all files + let mut all_issuers = Vec::new(); + + for issuer_file in &loaded.trusted_issuers { + let parsed_issuers = IssuerParser::parse_issuer(&issuer_file.content, &issuer_file.name) + .expect("Should parse issuers"); + all_issuers.extend(parsed_issuers); + } + + // Should have 2 issuers total (1 jans + 1 google) + assert_eq!(all_issuers.len(), 2, "Should have 2 issuers total"); + + // Verify issuer IDs + let ids: Vec = all_issuers.iter().map(|i| i.id.clone()).collect(); + assert!(ids.contains(&"jans_server".to_string())); + assert!(ids.contains(&"google_oauth".to_string())); + + // Create issuer map + let issuer_map = IssuerParser::create_issuer_map(all_issuers); + assert!(issuer_map.is_ok(), "Should create issuer map"); + assert_eq!(issuer_map.unwrap().len(), 2, "Map should have 2 issuers"); +} + +#[test] +fn test_parse_issuer_with_token_metadata() { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path(); + + // Create a complete policy store structure + create_test_policy_store(dir).expect("Failed to create test policy store"); + + // Create trusted-issuers directory + let issuers_dir = dir.join("trusted-issuers"); + fs::create_dir(&issuers_dir).unwrap(); + + // Add issuer with comprehensive token metadata + fs::write( + issuers_dir.join("comprehensive.json"), + r#"{ + "full_issuer": { + "name": "Full Feature Issuer", + "description": "Issuer with all token types", + "openid_configuration_endpoint": "https://full.test/.well-known/openid-configuration", + "token_metadata": { + "access_token": { + "trusted": true, + "entity_type_name": "App::access_token", + "user_id": "sub", + "role_mapping": "role", + "token_id": "jti" + }, + "id_token": { + "trusted": true, + "entity_type_name": "App::id_token", + "user_id": "sub", + "token_id": "jti" + }, + "userinfo_token": { + "trusted": true, + "entity_type_name": "App::userinfo_token", + "user_id": "sub" + } + } + } + }"#, + ) + .unwrap(); + + // Load the policy store + let loader = DefaultPolicyStoreLoader::new_physical(); + let loaded = loader + .load_directory(dir.to_str().unwrap()) + .expect("Expected directory load to succeed"); + + // Parse issuers + let mut all_issuers = Vec::new(); + + for issuer_file in &loaded.trusted_issuers { + let parsed_issuers = IssuerParser::parse_issuer(&issuer_file.content, &issuer_file.name) + .expect("Should parse issuers"); + all_issuers.extend(parsed_issuers); + } + + assert_eq!(all_issuers.len(), 1); + + let issuer = &all_issuers[0]; + assert_eq!(issuer.id, "full_issuer"); + assert_eq!(issuer.issuer.token_metadata.len(), 3); + + // Verify token metadata details + let access_token = issuer.issuer.token_metadata.get("access_token").unwrap(); + assert_eq!(access_token.entity_type_name, "App::access_token"); + assert_eq!(access_token.user_id, Some("sub".to_string())); + assert_eq!(access_token.role_mapping, Some("role".to_string())); +} + +#[test] +fn test_detect_duplicate_issuer_ids() { + // Create in-memory filesystem + let vfs = MemoryVfs::new(); + + // Create a complete policy store structure in memory + vfs.create_file( + "metadata.json", + r#"{ + "cedar_version": "4.4.0", + "policy_store": { + "id": "abc123def456", + "name": "Test Policy Store", + "version": "1.0.0" + } + }"# + .as_bytes(), + ) + .unwrap(); + + vfs.create_file( + "schema.cedarschema", + r#" +namespace TestApp { +entity User; +entity Resource; +action "read" appliesTo { + principal: [User], + resource: [Resource] +}; +} + "# + .as_bytes(), + ) + .unwrap(); + + // Create policies directory with a test policy + vfs.create_file( + "policies/test_policy.cedar", + b"permit(principal, action, resource);", + ) + .unwrap(); + + // Create trusted-issuers directory with duplicate IDs + vfs.create_file( + "trusted-issuers/file1.json", + r#"{ + "issuer1": { + "name": "Issuer One", + "description": "First instance", + "openid_configuration_endpoint": "https://issuer1.com/.well-known/openid-configuration", + "token_metadata": { + "access_token": { + "entity_type_name": "App::access_token" + } + } + } + }"# + .as_bytes(), + ) + .unwrap(); + + vfs.create_file( + "trusted-issuers/file2.json", + r#"{ + "issuer1": { + "name": "Issuer One Duplicate", + "description": "Duplicate instance", + "openid_configuration_endpoint": "https://issuer1.com/.well-known/openid-configuration", + "token_metadata": { + "id_token": { + "entity_type_name": "App::id_token" + } + } + } + }"# + .as_bytes(), + ) + .unwrap(); + + // Load the policy store using the in-memory filesystem + let loader = DefaultPolicyStoreLoader::new(vfs); + let loaded = loader + .load_directory("/") + .expect("Expected in-memory directory load to succeed"); + + // Parse issuers + let mut all_issuers = Vec::new(); + + for issuer_file in &loaded.trusted_issuers { + let parsed_issuers = IssuerParser::parse_issuer(&issuer_file.content, &issuer_file.name) + .expect("Should parse issuers"); + all_issuers.extend(parsed_issuers); + } + + // Detect duplicates + let validation = IssuerParser::validate_issuers(&all_issuers); + let errors = validation.expect_err("Should detect duplicate issuer IDs"); + assert_eq!(errors.len(), 1, "Should have 1 duplicate error"); + assert!( + errors[0].contains("issuer1"), + "Error should mention the duplicate issuer ID 'issuer1', got: {}", + errors[0] + ); + assert!( + errors[0].contains("file1.json") || errors[0].contains("file2.json"), + "Error should mention the source file, got: {}", + errors[0] + ); +} + +#[test] +fn test_issuer_missing_required_field() { + // Create in-memory filesystem + let vfs = MemoryVfs::new(); + + // Create a minimal policy store structure + vfs.create_file( + "metadata.json", + r#"{ + "cedar_version": "4.4.0", + "policy_store": { + "id": "abc123def456", + "name": "Test Policy Store", + "version": "1.0.0" + } + }"# + .as_bytes(), + ) + .unwrap(); + + vfs.create_file("schema.cedarschema", b"namespace TestApp { entity User; }") + .unwrap(); + + vfs.create_file( + "policies/test.cedar", + b"permit(principal, action, resource);", + ) + .unwrap(); + + // Create trusted-issuers directory with invalid issuer (missing name) + vfs.create_file( + "trusted-issuers/invalid.json", + r#"{ + "bad_issuer": { + "description": "Missing name field", + "openid_configuration_endpoint": "https://test.com/.well-known/openid-configuration" + } + }"# + .as_bytes(), + ) + .unwrap(); + + // Load the policy store using in-memory filesystem + let loader = DefaultPolicyStoreLoader::new(vfs); + let loaded = loader + .load_directory("/") + .expect("Expected in-memory directory load to succeed"); + + // Parse issuers - should fail + let result = IssuerParser::parse_issuer( + &loaded.trusted_issuers[0].content, + &loaded.trusted_issuers[0].name, + ); + + let err = result.expect_err("Should fail on missing required field"); + assert!( + matches!(&err, PolicyStoreError::TrustedIssuerError { .. }), + "Expected TrustedIssuerError, got: {:?}", + err + ); +} + +#[test] +fn test_complete_policy_store_with_issuers() { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path(); + + // Create a complete policy store structure + create_test_policy_store(dir).expect("Failed to create test policy store"); + + // Add entities + let entities_dir = dir.join("entities"); + fs::create_dir(&entities_dir).unwrap(); + fs::write( + entities_dir.join("users.json"), + r#"[ + { + "uid": {"type": "Jans::User", "id": "alice"}, + "attrs": {"email": "alice@example.com"}, + "parents": [] + } + ]"#, + ) + .unwrap(); + + // Add trusted issuers + let issuers_dir = dir.join("trusted-issuers"); + fs::create_dir(&issuers_dir).unwrap(); + fs::write( + issuers_dir.join("issuer.json"), + r#"{ + "main_issuer": { + "name": "Main Issuer", + "description": "Primary authentication provider", + "openid_configuration_endpoint": "https://auth.test/.well-known/openid-configuration", + "token_metadata": { + "access_token": { + "entity_type_name": "Jans::access_token", + "user_id": "sub" + } + } + } + }"#, + ) + .unwrap(); + + // Load the policy store + let loader = DefaultPolicyStoreLoader::new_physical(); + let loaded = loader + .load_directory(dir.to_str().unwrap()) + .expect("Expected directory load to succeed"); + + // Verify all components are loaded + assert_eq!(loaded.metadata.name(), "Test Policy Store"); + assert!(!loaded.schema.is_empty()); + assert!(!loaded.policies.is_empty()); + assert!(!loaded.entities.is_empty()); + assert!(!loaded.trusted_issuers.is_empty()); + + // Parse and validate all components + + // Schema + let parsed_schema = + ParsedSchema::parse(&loaded.schema, "schema.cedarschema").expect("Should parse schema"); + parsed_schema.validate().expect("Schema should be valid"); + + // Policies + let parsed_policies = + PhysicalLoader::parse_policies(&loaded.policies).expect("Should parse policies"); + let policy_set = PhysicalLoader::create_policy_set(parsed_policies, vec![]) + .expect("Should create policy set"); + + // Entities (parse without schema validation since this test focuses on issuers) + let mut all_entities = Vec::new(); + for entity_file in &loaded.entities { + let parsed_entities = EntityParser::parse_entities( + &entity_file.content, + &entity_file.name, + None, // No schema validation - this test is about issuer integration + ) + .expect("Should parse entities"); + all_entities.extend(parsed_entities); + } + let entity_store = + EntityParser::create_entities_store(all_entities).expect("Should create entity store"); + + // Issuers + let mut all_issuers = Vec::new(); + for issuer_file in &loaded.trusted_issuers { + let parsed_issuers = IssuerParser::parse_issuer(&issuer_file.content, &issuer_file.name) + .expect("Should parse issuers"); + all_issuers.extend(parsed_issuers); + } + let issuer_map = + IssuerParser::create_issuer_map(all_issuers).expect("Should create issuer map"); + + // Verify everything works together + assert!(!policy_set.is_empty()); + assert_eq!(entity_store.iter().count(), 1); + assert!(!format!("{:?}", parsed_schema.get_schema()).is_empty()); + assert_eq!(issuer_map.len(), 1); + assert!(issuer_map.contains_key("main_issuer")); +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn test_archive_vfs_end_to_end_from_file() { + let temp_dir = TempDir::new().unwrap(); + let archive_path = temp_dir.path().join("complete_store.cjar"); + + // Create a complete .cjar archive + let file = File::create(&archive_path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + + // Metadata + zip.start_file("metadata.json", options).unwrap(); + zip.write_all( + br#"{ + "cedar_version": "1.0.0", + "policy_store": { + "id": "abcdef123456", + "name": "Archive Test Store", + "version": "1.0.0" + } + }"#, + ) + .unwrap(); + + // Schema + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + zip.start_file("schema.cedarschema", options).unwrap(); + zip.write_all(b"namespace TestApp { entity User; }") + .unwrap(); + + // Policy + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + zip.start_file("policies/allow.cedar", options).unwrap(); + zip.write_all(b"permit(principal, action, resource);") + .unwrap(); + + // Entity + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + zip.start_file("entities/users.json", options).unwrap(); + zip.write_all( + br#"[{ + "uid": {"type": "TestApp::User", "id": "alice"}, + "attrs": {}, + "parents": [] + }]"#, + ) + .unwrap(); + + zip.finish().unwrap(); + + // Step 1: Create ArchiveVfs from file path + let archive_vfs = + ArchiveVfs::from_file(&archive_path).expect("Should create ArchiveVfs from .cjar file"); + + // Step 2: Create loader with ArchiveVfs + let loader = DefaultPolicyStoreLoader::new(archive_vfs); + + // Step 3: Load policy store from archive root + let loaded = loader + .load_directory(".") + .expect("Should load policy store from archive"); + + // Step 4: Verify all components loaded correctly + assert_eq!(loaded.metadata.name(), "Archive Test Store"); + assert_eq!(loaded.metadata.policy_store.id, "abcdef123456"); + assert!(!loaded.schema.is_empty()); + assert_eq!(loaded.policies.len(), 1); + assert_eq!(loaded.policies[0].name, "allow.cedar"); + assert_eq!(loaded.entities.len(), 1); + assert_eq!(loaded.entities[0].name, "users.json"); + + // Step 5: Verify components can be parsed + + let parsed_schema = ParsedSchema::parse(&loaded.schema, "schema.cedarschema") + .expect("Should parse schema from archive"); + + let parsed_entities = EntityParser::parse_entities( + &loaded.entities[0].content, + "users.json", + Some(parsed_schema.get_schema()), + ) + .expect("Should parse entities from archive"); + + assert_eq!(parsed_entities.len(), 1); +} + +#[test] +fn test_archive_vfs_end_to_end_from_bytes() { + // Create archive in memory using helper (simulates WASM fetching from network) + let archive_bytes = create_test_archive("WASM Archive Store", "fedcba654321", &[], &[]); + + // Create ArchiveVfs from bytes (works in WASM!) + let archive_vfs = + ArchiveVfs::from_buffer(archive_bytes).expect("Should create ArchiveVfs from bytes"); + + // Create loader and load policy store + let loader = DefaultPolicyStoreLoader::new(archive_vfs); + let loaded = loader + .load_directory(".") + .expect("Should load policy store from archive bytes"); + + // Verify loaded correctly + assert_eq!(loaded.metadata.name(), "WASM Archive Store"); + assert_eq!(loaded.metadata.policy_store.id, "fedcba654321"); + assert!(!loaded.schema.is_empty()); + assert_eq!(loaded.policies.len(), 1); +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn test_archive_vfs_with_manifest_validation() { + let temp_dir = TempDir::new().unwrap(); + let archive_path = temp_dir.path().join("store_with_manifest.cjar"); + + // Create archive with manifest + let file = File::create(&archive_path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + + // Metadata + let metadata_content = br#"{ + "cedar_version": "1.0.0", + "policy_store": { + "id": "abc123def456", + "name": "Manifest Test", + "version": "1.0.0" + } + }"#; + zip.start_file("metadata.json", options).unwrap(); + zip.write_all(metadata_content).unwrap(); + + // Minimal schema + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + zip.start_file("schema.cedarschema", options).unwrap(); + zip.write_all(b"namespace Test { entity User; }").unwrap(); + + // Minimal policy (required) + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + zip.start_file("policies/test.cedar", options).unwrap(); + zip.write_all(b"permit(principal, action, resource);") + .unwrap(); + + // Manifest (simplified - no checksums for this test) + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + zip.start_file("manifest.json", options).unwrap(); + zip.write_all( + br#"{ + "policy_store_id": "abc123def456", + "generated_date": "2024-01-01T00:00:00Z", + "files": {} + }"#, + ) + .unwrap(); + + zip.finish().unwrap(); + + // Step 1: Create ArchiveVfs + let archive_vfs = ArchiveVfs::from_file(&archive_path).expect("Should create ArchiveVfs"); + + // Step 2: Load policy store + let loader = DefaultPolicyStoreLoader::new(archive_vfs); + let loaded = loader + .load_directory(".") + .expect("Should load with manifest"); + + // Step 3: Verify manifest was loaded + assert!(loaded.manifest.is_some()); + let manifest = loaded.manifest.as_ref().unwrap(); + assert_eq!(manifest.policy_store_id, "abc123def456"); + + // Step 4: Show that ManifestValidator can work with ArchiveVfs + let archive_vfs2 = + ArchiveVfs::from_file(&archive_path).expect("Should create second ArchiveVfs"); + let validator = ManifestValidator::new(archive_vfs2, PathBuf::from(".")); + + // This demonstrates that manifest validation works with ANY VfsFileSystem, + // including ArchiveVfs (not just PhysicalVfs) + let validation_result = validator.validate(Some("abc123def456")); + + // Validation should succeed when the expected ID matches the manifest's policy_store_id + assert!( + validation_result.is_valid, + "expected validation to succeed when IDs match, but got errors: {:?}", + validation_result.errors + ); +} + +#[test] +fn test_archive_vfs_with_multiple_policies() { + let mut archive_bytes = Vec::new(); + { + let cursor = Cursor::new(&mut archive_bytes); + let mut zip = zip::ZipWriter::new(cursor); + + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + + // Metadata + zip.start_file("metadata.json", options).unwrap(); + zip.write_all( + br#"{ + "cedar_version": "1.0.0", + "policy_store": { + "id": "def456abc123", + "name": "Nested Structure", + "version": "1.0.0" + } + }"#, + ) + .unwrap(); + + // Schema + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + zip.start_file("schema.cedarschema", options).unwrap(); + zip.write_all(b"namespace App { entity User; }").unwrap(); + + // Multiple policies in subdirectories (loader recursively scans subdirectories) + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + zip.start_file("policies/allow/basic.cedar", options) + .unwrap(); + zip.write_all(b"permit(principal, action, resource);") + .unwrap(); + + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + zip.start_file("policies/allow/advanced.cedar", options) + .unwrap(); + zip.write_all(b"permit(principal == App::User::\"admin\", action, resource);") + .unwrap(); + + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + zip.start_file("policies/deny/restricted.cedar", options) + .unwrap(); + zip.write_all(b"forbid(principal, action, resource);") + .unwrap(); + + zip.finish().unwrap(); + } + + let archive_vfs = ArchiveVfs::from_buffer(archive_bytes).expect("Should create ArchiveVfs"); + + let loader = DefaultPolicyStoreLoader::new(archive_vfs); + let loaded = loader.load_directory(".").expect("Should load policies"); + + // Verify all policies loaded recursively from subdirectories + assert_eq!(loaded.policies.len(), 3); + + let policy_names: Vec<_> = loaded.policies.iter().map(|p| &p.name).collect(); + assert!(policy_names.contains(&&"basic.cedar".to_string())); + assert!(policy_names.contains(&&"advanced.cedar".to_string())); + assert!(policy_names.contains(&&"restricted.cedar".to_string())); +} + +#[test] +fn test_archive_vfs_vs_physical_vfs_equivalence() { + // This test demonstrates that ArchiveVfs and PhysicalVfs are + // functionally equivalent from the loader's perspective + + // Create identical content + let metadata_json = br#"{ + "cedar_version": "1.0.0", + "policy_store": { + "id": "fedcba987654", + "name": "Equivalence Test", + "version": "1.0.0" + } + }"#; + let schema_content = b"namespace Equiv { entity User; }"; + let policy_content = b"permit(principal, action, resource);"; + + // Create archive + let mut archive_bytes = Vec::new(); + { + let cursor = Cursor::new(&mut archive_bytes); + let mut zip = zip::ZipWriter::new(cursor); + + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + zip.start_file("metadata.json", options).unwrap(); + zip.write_all(metadata_json).unwrap(); + + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + zip.start_file("schema.cedarschema", options).unwrap(); + zip.write_all(schema_content).unwrap(); + + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + zip.start_file("policies/test.cedar", options).unwrap(); + zip.write_all(policy_content).unwrap(); + + zip.finish().unwrap(); + } + + // Load using ArchiveVfs + let archive_vfs = ArchiveVfs::from_buffer(archive_bytes).unwrap(); + let loader = DefaultPolicyStoreLoader::new(archive_vfs); + let loaded = loader.load_directory(".").unwrap(); + + // Verify results are identical regardless of VFS implementation + assert_eq!(loaded.metadata.policy_store.id, "fedcba987654"); + assert_eq!(loaded.metadata.name(), "Equivalence Test"); + assert_eq!(loaded.policies.len(), 1); + assert!(loaded.schema.contains("Equiv")); +} diff --git a/jans-cedarling/cedarling/src/common/policy_store/log_entry.rs b/jans-cedarling/cedarling/src/common/policy_store/log_entry.rs new file mode 100644 index 00000000000..7bf6dac4bea --- /dev/null +++ b/jans-cedarling/cedarling/src/common/policy_store/log_entry.rs @@ -0,0 +1,102 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Log entries for policy store operations. + +use serde::Serialize; + +use crate::log::interface::{Indexed, Loggable}; +use crate::log::{BaseLogEntry, LogLevel}; + +/// Log entry for policy store operations. +#[derive(Serialize, Clone)] +pub struct PolicyStoreLogEntry { + #[serde(flatten)] + base: BaseLogEntry, + msg: String, +} + +impl PolicyStoreLogEntry { + /// Create a new policy store log entry with an explicit or default log level. + /// + /// Use this constructor when you need fine-grained control over the log level, + /// such as DEBUG or ERROR levels, or when the level is determined dynamically. + /// If no level is provided, defaults to TRACE. This is the most flexible option + /// for system-level policy store logs where the severity needs to be explicitly + /// controlled based on the operation context. + pub fn new(msg: impl Into, level: Option) -> Self { + let base = BaseLogEntry::new_system_opt_request_id(level.unwrap_or(LogLevel::TRACE), None); + Self { + base, + msg: msg.into(), + } + } + + /// Create an info-level log entry for general informational messages. + /// + /// Use this convenience method for standard informational logs about policy store + /// operations, such as successful loads, completed validations, or routine status + /// updates. This is the recommended choice for most non-error, non-warning policy + /// store events that should be visible in production logs. + pub fn info(msg: impl Into) -> Self { + Self::new(msg, Some(LogLevel::INFO)) + } + + /// Create a warning-level log entry for non-critical issues. + /// + /// Use this convenience method for warnings that don't prevent operation but should + /// be noted, such as missing optional files, deprecated feature usage, or + /// recoverable validation issues. These logs help identify potential problems + /// without disrupting normal policy store functionality. + pub fn warn(msg: impl Into) -> Self { + Self::new(msg, Some(LogLevel::WARN)) + } +} + +impl Loggable for PolicyStoreLogEntry { + fn get_log_level(&self) -> Option { + self.base.get_log_level() + } +} + +impl Indexed for PolicyStoreLogEntry { + fn get_id(&self) -> uuid7::Uuid { + self.base.get_id() + } + + fn get_additional_ids(&self) -> Vec { + self.base.get_additional_ids() + } + + fn get_tags(&self) -> Vec<&str> { + self.base.get_tags() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_with_level() { + let entry = PolicyStoreLogEntry::new("Test message", Some(LogLevel::INFO)); + assert_eq!(entry.msg, "Test message"); + assert_eq!(entry.get_log_level(), Some(LogLevel::INFO)); + } + + #[test] + fn test_info_helper() { + let entry = PolicyStoreLogEntry::info("Info message"); + assert_eq!(entry.msg, "Info message"); + assert_eq!(entry.get_log_level(), Some(LogLevel::INFO)); + } + + #[test] + fn test_warn_helper() { + let entry = PolicyStoreLogEntry::warn("Warning message"); + assert_eq!(entry.msg, "Warning message"); + assert_eq!(entry.get_log_level(), Some(LogLevel::WARN)); + } +} diff --git a/jans-cedarling/cedarling/src/common/policy_store/manager.rs b/jans-cedarling/cedarling/src/common/policy_store/manager.rs new file mode 100644 index 00000000000..5a84863105a --- /dev/null +++ b/jans-cedarling/cedarling/src/common/policy_store/manager.rs @@ -0,0 +1,729 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Policy Store Manager - Converts new format to legacy format. +//! +//! This module provides the conversion layer between `LoadedPolicyStore` (new directory/archive format) +//! and `PolicyStore` (legacy format used by the rest of Cedarling). +//! +//! # Architecture +//! +//! ```text +//! LoadedPolicyStore (new) PolicyStore (legacy) +//! ├── metadata → name, version, description, cedar_version +//! ├── schema (raw string) → schema: CedarSchema +//! ├── policies: Vec → policies: PoliciesContainer +//! ├── trusted_issuers → trusted_issuers: HashMap +//! └── entities → default_entities: HashMap +//! ``` + +use super::entity_parser::EntityParser; +use super::issuer_parser::IssuerParser; +use super::loader::LoadedPolicyStore; +use super::log_entry::PolicyStoreLogEntry; +use super::policy_parser::PolicyParser; +use super::{PoliciesContainer, PolicyStore, TrustedIssuer}; +use crate::common::cedar_schema::CedarSchema; +use crate::common::cedar_schema::cedar_json::CedarSchemaJson; +use crate::common::default_entities::parse_default_entities_with_warns; +use crate::log::Logger; +use crate::log::interface::LogWriter; +use cedar_policy::PolicySet; +use cedar_policy_core::extensions::Extensions; +use cedar_policy_core::validator::ValidatorSchema; +use semver::Version; +use std::collections::HashMap; + +/// Errors that can occur during policy store conversion. +#[derive(Debug, thiserror::Error)] +pub enum ConversionError { + /// Schema conversion failed + #[error("Failed to convert schema: {0}")] + SchemaConversion(String), + + /// Policy conversion failed + #[error("Failed to convert policies: {0}")] + PolicyConversion(String), + + /// Trusted issuer conversion failed + #[error("Failed to convert trusted issuers: {0}")] + IssuerConversion(String), + + /// Entity conversion failed + #[error("Failed to convert entities: {0}")] + EntityConversion(String), + + /// Version parsing failed + #[error("Failed to parse cedar version '{version}': {details}")] + VersionParsing { version: String, details: String }, + + /// Policy set creation failed + #[error("Failed to create policy set: {0}")] + PolicySetCreation(String), +} + +/// Policy Store Manager handles conversion between new and legacy formats. +pub struct PolicyStoreManager; + +impl PolicyStoreManager { + /// Convert a `LoadedPolicyStore` (new format) to `PolicyStore` (legacy format). + /// + /// This is the main entry point for converting policy stores loaded from + /// directory or archive format into the legacy format used by the rest of Cedarling. + /// + /// # Arguments + /// + /// * `loaded` - The loaded policy store from the new loader + /// + /// # Returns + /// + /// Returns a `PolicyStore` that can be used with existing Cedarling services. + /// + /// # Errors + /// + /// Returns `ConversionError` if any component fails to convert. + pub fn convert_to_legacy(loaded: LoadedPolicyStore) -> Result { + Self::convert_to_legacy_with_logger(loaded, None) + } + + /// Convert a `LoadedPolicyStore` to `PolicyStore` with optional logging. + /// + /// This version accepts an optional logger for structured logging during conversion. + /// Use this when a logger is available to get detailed conversion logs. + /// + /// # Arguments + /// + /// * `loaded` - The loaded policy store from the new loader + /// * `logger` - Optional logger for structured logging + /// + /// # Returns + /// + /// Returns a `PolicyStore` that can be used with existing Cedarling services. + /// + /// # Errors + /// + /// Returns `ConversionError` if any component fails to convert. + pub fn convert_to_legacy_with_logger( + loaded: LoadedPolicyStore, + logger: Option, + ) -> Result { + // Log manifest info if available + if let Some(manifest) = &loaded.manifest { + logger.log_any(PolicyStoreLogEntry::info(format!( + "Converting policy store '{}' (generated: {})", + manifest.policy_store_id, manifest.generated_date + ))); + } + + // 1. Convert schema + let cedar_schema = Self::convert_schema(&loaded.schema)?; + + // 2. Convert policies and templates into a single PoliciesContainer + let policies_container = + Self::convert_policies_and_templates(&loaded.policies, &loaded.templates)?; + + // 3. Convert trusted issuers + let trusted_issuers = Self::convert_trusted_issuers(&loaded.trusted_issuers)?; + + // 4. Convert entities (logs hierarchy warnings if logger provided) + let raw_entities = Self::convert_entities(&loaded.entities, &logger)?; + + // Convert raw entities to DefaultEntitiesWithWarns + let default_entities = parse_default_entities_with_warns(raw_entities).map_err(|e| { + ConversionError::EntityConversion(format!("Failed to parse default entities: {}", e)) + })?; + + // 5. Parse cedar version + let cedar_version = Self::parse_cedar_version(&loaded.metadata.cedar_version)?; + + logger.log_any(PolicyStoreLogEntry::info(format!( + "Policy store conversion complete: {} policies, {} issuers, {} entities", + policies_container.get_set().policies().count(), + trusted_issuers.as_ref().map(|i| i.len()).unwrap_or(0), + default_entities.entities().len() + ))); + + Ok(PolicyStore { + name: loaded.metadata.policy_store.name, + version: Some(loaded.metadata.policy_store.version), + description: loaded.metadata.policy_store.description, + cedar_version: Some(cedar_version), + schema: cedar_schema, + policies: policies_container, + trusted_issuers, + default_entities, + }) + } + + /// Convert raw schema string to `CedarSchema`. + /// + /// Uses `ParsedSchema::parse` to parse and validate the schema, then converts + /// to the `CedarSchema` format required by the legacy system. + /// + /// The `CedarSchema` requires: + /// - `schema: cedar_policy::Schema` + /// - `json: CedarSchemaJson` + /// - `validator_schema: ValidatorSchema` + fn convert_schema(schema_content: &str) -> Result { + use super::schema_parser::ParsedSchema; + use cedar_policy::SchemaFragment; + use std::str::FromStr; + + // Parse and validate schema + let parsed_schema = + ParsedSchema::parse(schema_content, "schema.cedarschema").map_err(|e| { + ConversionError::SchemaConversion(format!("Failed to parse schema: {}", e)) + })?; + + // Validate the schema + parsed_schema.validate().map_err(|e| { + ConversionError::SchemaConversion(format!("Schema validation failed: {}", e)) + })?; + + // Get the Cedar schema from the parsed result + let schema = parsed_schema.get_schema().clone(); + + // Convert to JSON for CedarSchemaJson and ValidatorSchema + // NOTE: This parses the schema content again (SchemaFragment::from_str). + // For large schemas, this double-parsing could be optimized by having + // ParsedSchema return both the validated schema and the fragment, but + // this is a performance consideration rather than a correctness issue. + let fragment = SchemaFragment::from_str(schema_content).map_err(|e| { + ConversionError::SchemaConversion(format!("Failed to parse schema fragment: {}", e)) + })?; + + let json_string = fragment.to_json_string().map_err(|e| { + ConversionError::SchemaConversion(format!("Failed to serialize schema to JSON: {}", e)) + })?; + + // Parse CedarSchemaJson + let json: CedarSchemaJson = serde_json::from_str(&json_string).map_err(|e| { + ConversionError::SchemaConversion(format!("Failed to parse CedarSchemaJson: {}", e)) + })?; + + // Create ValidatorSchema + let validator_schema = ValidatorSchema::from_json_str( + &json_string, + Extensions::all_available(), + ) + .map_err(|e| { + ConversionError::SchemaConversion(format!("Failed to create ValidatorSchema: {}", e)) + })?; + + Ok(CedarSchema { + schema, + json, + validator_schema, + }) + } + + /// Convert policy and template files to `PoliciesContainer`. + /// + /// The `PoliciesContainer` requires: + /// - `policy_set: cedar_policy::PolicySet` (includes both policies and templates) + /// - `raw_policy_info: HashMap` (for descriptions) + fn convert_policies_and_templates( + policy_files: &[super::loader::PolicyFile], + template_files: &[super::loader::PolicyFile], + ) -> Result { + if policy_files.is_empty() && template_files.is_empty() { + // Return empty policy set + let policy_set = PolicySet::new(); + return Ok(PoliciesContainer::new_empty(policy_set)); + } + + // Parse each policy file + let mut parsed_policies = Vec::with_capacity(policy_files.len()); + for file in policy_files { + let parsed = PolicyParser::parse_policy(&file.content, &file.name).map_err(|e| { + ConversionError::PolicyConversion(format!("Failed to parse '{}': {}", file.name, e)) + })?; + parsed_policies.push(parsed); + } + + // Parse each template file + let mut parsed_templates = Vec::with_capacity(template_files.len()); + for file in template_files { + let parsed = PolicyParser::parse_template(&file.content, &file.name).map_err(|e| { + ConversionError::PolicyConversion(format!( + "Failed to parse template '{}': {}", + file.name, e + )) + })?; + parsed_templates.push(parsed); + } + + // Create policy set using PolicyParser (includes both policies and templates) + let policy_set = + PolicyParser::create_policy_set(parsed_policies.clone(), parsed_templates.clone()) + .map_err(|e| ConversionError::PolicySetCreation(e.to_string()))?; + + // Build raw_policy_info for descriptions (policies only, templates don't have descriptions in legacy format) + let raw_policy_info = parsed_policies + .into_iter() + .map(|p| (p.id.to_string(), format!("Policy from {}", p.filename))) + .collect(); + + Ok(PoliciesContainer::new(policy_set, raw_policy_info)) + } + + /// Convert issuer files to `HashMap`. + fn convert_trusted_issuers( + issuer_files: &[super::loader::IssuerFile], + ) -> Result>, ConversionError> { + if issuer_files.is_empty() { + return Ok(None); + } + + let mut all_issuers = Vec::new(); + for file in issuer_files { + let parsed = IssuerParser::parse_issuer(&file.content, &file.name).map_err(|e| { + ConversionError::IssuerConversion(format!("Failed to parse '{}': {}", file.name, e)) + })?; + all_issuers.extend(parsed); + } + + // Validate for duplicates - include content in error for debugging + if let Err(errors) = IssuerParser::validate_issuers(&all_issuers) { + // Return validation errors directly, joined into a single string + let error_details = errors + .iter() + .map(|e| e.to_string()) + .collect::>() + .join("; "); + return Err(ConversionError::IssuerConversion(error_details)); + } + + // Create issuer map + let issuer_map = IssuerParser::create_issuer_map(all_issuers) + .map_err(|e| ConversionError::IssuerConversion(e.to_string()))?; + + Ok(Some(issuer_map)) + } + + /// Convert entity files to `HashMap`. + /// + /// This function: + /// 1. Parses all entity files + /// 2. Detects duplicate entity UIDs (returns error if found) + /// 3. Optionally validates entity hierarchy (parent references - logs warnings if logger provided) + /// 4. Converts to the required HashMap format + /// + /// # Arguments + /// + /// * `entity_files` - The entity files to convert + /// * `logger` - Optional logger for hierarchy warnings + fn convert_entities( + entity_files: &[super::loader::EntityFile], + logger: &Option, + ) -> Result>, ConversionError> { + if entity_files.is_empty() { + return Ok(None); + } + + // Step 1: Parse all entity files + let mut all_parsed_entities = Vec::new(); + for file in entity_files { + let parsed = + EntityParser::parse_entities(&file.content, &file.name, None).map_err(|e| { + ConversionError::EntityConversion(format!( + "Failed to parse '{}': {}", + file.name, e + )) + })?; + all_parsed_entities.extend(parsed); + } + + // Step 2: Detect duplicate entity UIDs (warns but doesn't fail on duplicates) + // Note: We clone all_parsed_entities here because EntityParser::detect_duplicates + // takes ownership of the Vec. This preserves the original for later hierarchy validation. + // Duplicates are handled gracefully - the latest entity wins and a warning is logged. + let unique_entities = EntityParser::detect_duplicates(all_parsed_entities.clone(), logger); + + // Step 3: Validate entity hierarchy (optional - parent entities may be provided at runtime) + // This ensures all parent references point to entities that exist in this store + // Note: Hierarchy validation errors are non-fatal since parent entities + // might be provided at runtime via authorization requests + if let Err(warnings) = EntityParser::validate_hierarchy(&all_parsed_entities) { + logger.log_any(PolicyStoreLogEntry::warn(format!( + "Entity hierarchy validation warnings (non-fatal): {:?}", + warnings + ))); + } + + // Step 4: Validate entities can form a valid Cedar entity store + // This validates entity constraints like types and attribute compatibility + EntityParser::create_entities_store(all_parsed_entities).map_err(|e| { + ConversionError::EntityConversion(format!("Failed to create entity store: {}", e)) + })?; + + // Step 5: Convert to HashMap + let mut result = HashMap::with_capacity(unique_entities.len()); + for (uid, parsed_entity) in unique_entities { + let json_value = parsed_entity.entity.to_json_value().map_err(|e| { + // Include the original content in the error for debugging + ConversionError::EntityConversion(format!( + "Failed to serialize entity '{}' from '{}': {}. Original content: {}", + uid, + parsed_entity.filename, + e, + if parsed_entity.content.len() > 200 { + format!("{}...(truncated)", &parsed_entity.content[..200]) + } else { + parsed_entity.content.clone() + } + )) + })?; + result.insert(uid.to_string(), json_value); + } + + Ok(Some(result)) + } + + /// Parse cedar version string to `semver::Version`. + fn parse_cedar_version(version_str: &str) -> Result { + // Handle optional "v" prefix + let version_str = version_str.strip_prefix('v').unwrap_or(version_str); + + Version::parse(version_str).map_err(|e| ConversionError::VersionParsing { + version: version_str.to_string(), + details: e.to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::policy_store::loader::{EntityFile, IssuerFile, PolicyFile}; + use crate::common::policy_store::metadata::{PolicyStoreInfo, PolicyStoreMetadata}; + + fn create_test_metadata() -> PolicyStoreMetadata { + PolicyStoreMetadata { + cedar_version: "4.0.0".to_string(), + policy_store: PolicyStoreInfo { + id: "test123".to_string(), + name: "Test Store".to_string(), + description: Some("A test policy store".to_string()), + version: "1.0.0".to_string(), + created_date: None, + updated_date: None, + }, + } + } + + #[test] + fn test_parse_cedar_version_valid() { + let version = PolicyStoreManager::parse_cedar_version("4.0.0").unwrap(); + assert_eq!(version.major, 4); + assert_eq!(version.minor, 0); + assert_eq!(version.patch, 0); + } + + #[test] + fn test_parse_cedar_version_with_v_prefix() { + let version = PolicyStoreManager::parse_cedar_version("v4.1.2").unwrap(); + assert_eq!(version.major, 4); + assert_eq!(version.minor, 1); + assert_eq!(version.patch, 2); + } + + #[test] + fn test_parse_cedar_version_invalid() { + let result = PolicyStoreManager::parse_cedar_version("invalid"); + let err = result.expect_err("Expected error for invalid version format"); + assert!( + matches!(err, ConversionError::VersionParsing { .. }), + "Expected VersionParsing error, got: {:?}", + err + ); + } + + #[test] + fn test_convert_schema_valid() { + let schema_content = r#" + namespace TestApp { + entity User; + entity Resource; + action "read" appliesTo { + principal: [User], + resource: [Resource] + }; + } +"#; + + let result = PolicyStoreManager::convert_schema(schema_content); + assert!( + result.is_ok(), + "Schema conversion failed: {:?}", + result.err() + ); + + let cedar_schema = result.unwrap(); + // Verify schema has expected entity types + let entity_types: Vec<_> = cedar_schema.schema.entity_types().collect(); + assert!(!entity_types.is_empty()); + } + + #[test] + fn test_convert_schema_invalid() { + let schema_content = "this is not valid cedar schema syntax {{{"; + let result = PolicyStoreManager::convert_schema(schema_content); + let err = result.expect_err("Expected error for invalid Cedar schema syntax"); + assert!( + matches!(err, ConversionError::SchemaConversion(_)), + "Expected SchemaConversion error, got: {:?}", + err + ); + } + + #[test] + fn test_convert_policies_valid() { + let policy_files = vec![ + PolicyFile { + name: "allow.cedar".to_string(), + content: "permit(principal, action, resource);".to_string(), + }, + PolicyFile { + name: "deny.cedar".to_string(), + content: "forbid(principal, action, resource);".to_string(), + }, + ]; + let template_files: Vec = vec![]; + + let result = + PolicyStoreManager::convert_policies_and_templates(&policy_files, &template_files); + assert!( + result.is_ok(), + "Policy conversion failed: {:?}", + result.err() + ); + + let container = result.unwrap(); + assert!(!container.get_set().is_empty()); + } + + #[test] + fn test_convert_policies_with_templates() { + let policy_files = vec![PolicyFile { + name: "allow.cedar".to_string(), + content: "permit(principal, action, resource);".to_string(), + }]; + let template_files = vec![PolicyFile { + name: "template.cedar".to_string(), + content: "permit(principal == ?principal, action, resource);".to_string(), + }]; + + let result = + PolicyStoreManager::convert_policies_and_templates(&policy_files, &template_files); + assert!( + result.is_ok(), + "Policy/template conversion failed: {:?}", + result.err() + ); + + let container = result.unwrap(); + assert!(!container.get_set().is_empty()); + } + + #[test] + fn test_convert_policies_empty() { + let policy_files: Vec = vec![]; + let template_files: Vec = vec![]; + let result = + PolicyStoreManager::convert_policies_and_templates(&policy_files, &template_files); + assert!(result.is_ok()); + + let container = result.unwrap(); + assert!(container.get_set().is_empty()); + } + + #[test] + fn test_convert_policies_invalid() { + let policy_files = vec![PolicyFile { + name: "invalid.cedar".to_string(), + content: "this is not valid cedar policy".to_string(), + }]; + let template_files: Vec = vec![]; + + let result = + PolicyStoreManager::convert_policies_and_templates(&policy_files, &template_files); + let err = result.expect_err("Expected ConversionError for invalid policy syntax"); + assert!( + matches!(err, ConversionError::PolicyConversion(_)), + "Expected PolicyConversion error, got: {:?}", + err + ); + } + + #[test] + fn test_convert_trusted_issuers_valid() { + let issuer_files = vec![IssuerFile { + name: "issuer.json".to_string(), + content: r#"{ + "test_issuer": { + "name": "Test Issuer", + "description": "A test issuer", + "openid_configuration_endpoint": "https://test.com/.well-known/openid-configuration", + "token_metadata": { + "access_token": { + "entity_type_name": "Test::access_token" + } + } + } + }"# + .to_string(), + }]; + + let result = PolicyStoreManager::convert_trusted_issuers(&issuer_files); + assert!( + result.is_ok(), + "Issuer conversion failed: {:?}", + result.err() + ); + + let issuers = result.unwrap(); + assert!(issuers.is_some()); + let issuers = issuers.unwrap(); + assert_eq!(issuers.len(), 1); + assert!(issuers.contains_key("test_issuer")); + } + + #[test] + fn test_convert_trusted_issuers_empty() { + let issuer_files: Vec = vec![]; + let result = PolicyStoreManager::convert_trusted_issuers(&issuer_files); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_convert_entities_valid() { + let entity_files = vec![EntityFile { + name: "users.json".to_string(), + content: r#"[ + { + "uid": {"type": "User", "id": "alice"}, + "attrs": {"name": "Alice"}, + "parents": [] + } + ]"# + .to_string(), + }]; + + let result = PolicyStoreManager::convert_entities(&entity_files, &None); + assert!( + result.is_ok(), + "Entity conversion failed: {:?}", + result.err() + ); + + let entities = result.unwrap(); + assert!(entities.is_some()); + let entities = entities.unwrap(); + assert_eq!(entities.len(), 1); + } + + #[test] + fn test_convert_entities_empty() { + let entity_files: Vec = vec![]; + let result = PolicyStoreManager::convert_entities(&entity_files, &None); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_convert_to_legacy_minimal() { + let loaded = LoadedPolicyStore { + metadata: create_test_metadata(), + manifest: None, + schema: r#" + namespace TestApp { + entity User; + action "read" appliesTo { + principal: [User], + resource: [User] + }; + } + "# + .to_string(), + policies: vec![PolicyFile { + name: "test.cedar".to_string(), + content: "permit(principal, action, resource);".to_string(), + }], + templates: vec![], + entities: vec![], + trusted_issuers: vec![], + }; + + let result = PolicyStoreManager::convert_to_legacy(loaded); + assert!(result.is_ok(), "Conversion failed: {:?}", result.err()); + + let store = result.unwrap(); + assert_eq!(store.name, "Test Store"); + assert_eq!(store.version, Some("1.0.0".to_string())); + assert_eq!(store.description, Some("A test policy store".to_string())); + assert!(store.cedar_version.is_some()); + assert!(!store.policies.get_set().is_empty()); + assert!(store.trusted_issuers.is_none()); + assert_eq!(store.default_entities.entities().len(), 0); + } + + #[test] + fn test_convert_to_legacy_full() { + let loaded = LoadedPolicyStore { + metadata: create_test_metadata(), + manifest: None, + schema: r#" + namespace TestApp { + entity User; + action "read" appliesTo { + principal: [User], + resource: [User] + }; + } + "# + .to_string(), + policies: vec![PolicyFile { + name: "test.cedar".to_string(), + content: "permit(principal, action, resource);".to_string(), + }], + templates: vec![], + entities: vec![EntityFile { + name: "users.json".to_string(), + content: r#"[{"uid": {"type": "User", "id": "alice"}, "attrs": {}, "parents": []}]"# + .to_string(), + }], + trusted_issuers: vec![IssuerFile { + name: "issuer.json".to_string(), + content: r#"{ + "main": { + "name": "Main Issuer", + "description": "Primary issuer", + "openid_configuration_endpoint": "https://auth.test/.well-known/openid-configuration", + "token_metadata": { + "access_token": { + "entity_type_name": "Test::access_token" + } + } + } + }"# + .to_string(), + }], +}; + + let result = PolicyStoreManager::convert_to_legacy(loaded); + assert!(result.is_ok(), "Conversion failed: {:?}", result.err()); + + let store = result.unwrap(); + assert!(store.trusted_issuers.is_some()); + assert!(store.default_entities.entities().len() > 0); + + let issuers = store.trusted_issuers.unwrap(); + assert!(issuers.contains_key("main")); + + assert_eq!(store.default_entities.entities().len(), 1); + } +} diff --git a/jans-cedarling/cedarling/src/common/policy_store/manifest_validator.rs b/jans-cedarling/cedarling/src/common/policy_store/manifest_validator.rs new file mode 100644 index 00000000000..5a8f6d6beb9 --- /dev/null +++ b/jans-cedarling/cedarling/src/common/policy_store/manifest_validator.rs @@ -0,0 +1,715 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Manifest-based integrity validation for policy stores. +//! +//! This module provides functionality to validate the integrity of a policy store +//! using a manifest file that contains SHA-256 checksums for all files. + +use std::collections::HashSet; +use std::path::PathBuf; + +use hex; +use sha2::{Digest, Sha256}; + +use super::errors::{ManifestErrorType, PolicyStoreError}; +use super::metadata::PolicyStoreManifest; +use super::vfs_adapter::VfsFileSystem; + +/// Result of manifest validation with detailed information. +#[derive(Debug, Clone, PartialEq)] +#[cfg(not(target_arch = "wasm32"))] +pub struct ManifestValidationResult { + /// Whether validation passed (all required checks passed) + pub is_valid: bool, + /// Files that passed validation + pub validated_files: Vec, + /// Files found in policy store but not listed in manifest (warnings) + pub unlisted_files: Vec, + /// Errors encountered during validation + pub errors: Vec, +} + +/// Detailed error information for manifest validation failures. +#[derive(Debug, Clone, PartialEq)] +#[cfg(not(target_arch = "wasm32"))] +pub struct ManifestValidationError { + /// Type of error + pub error_type: ManifestErrorType, + /// File path related to the error (if applicable) + pub file: Option, +} + +#[cfg(not(target_arch = "wasm32"))] +impl ManifestValidationResult { + /// Create a new validation result. + pub fn new() -> Self { + Self { + is_valid: true, + validated_files: Vec::new(), + unlisted_files: Vec::new(), + errors: Vec::new(), + } + } + + /// Add an error to the validation result and mark as invalid. + fn add_error(&mut self, error_type: ManifestErrorType, file: Option) { + self.is_valid = false; + self.errors + .push(ManifestValidationError { error_type, file }); + } + + /// Add a validated file. + fn add_validated_file(&mut self, file: String) { + self.validated_files.push(file); + } + + /// Add an unlisted file (warning). + fn add_unlisted_file(&mut self, file: String) { + self.unlisted_files.push(file); + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl Default for ManifestValidationResult { + fn default() -> Self { + Self::new() + } +} + +/// Manifest validator for policy store integrity validation. +#[cfg(not(target_arch = "wasm32"))] +pub struct ManifestValidator { + vfs: V, + base_path: PathBuf, +} + +#[cfg(not(target_arch = "wasm32"))] +impl ManifestValidator { + /// Create a new manifest validator. + pub fn new(vfs: V, base_path: PathBuf) -> Self { + Self { vfs, base_path } + } + + /// Load and parse the manifest file. + pub fn load_manifest(&self) -> Result { + let manifest_path = format!("{}/manifest.json", self.base_path.display()); + + // Check if manifest exists + if !self.vfs.exists(&manifest_path) { + return Err(PolicyStoreError::ManifestError { + err: ManifestErrorType::ManifestNotFound, + }); + } + + // Read manifest content + let content_bytes = + self.vfs + .read_file(&manifest_path) + .map_err(|e| PolicyStoreError::FileReadError { + path: manifest_path.clone(), + source: e, + })?; + + let content = + String::from_utf8(content_bytes).map_err(|e| PolicyStoreError::FileReadError { + path: manifest_path.clone(), + source: std::io::Error::new(std::io::ErrorKind::InvalidData, e), + })?; + + // Parse manifest JSON + let manifest: PolicyStoreManifest = + serde_json::from_str(&content).map_err(|e| PolicyStoreError::ManifestError { + err: ManifestErrorType::ParseError(e.to_string()), + })?; + + Ok(manifest) + } + + /// Compute SHA-256 checksum for a file. + /// + /// Useful for manifest generation and file integrity verification in tests and tooling. + #[cfg(test)] + pub fn compute_checksum(&self, file_path: &str) -> Result { + let content_bytes = + self.vfs + .read_file(file_path) + .map_err(|e| PolicyStoreError::FileReadError { + path: file_path.to_string(), + source: e, + })?; + + let mut hasher = Sha256::new(); + hasher.update(&content_bytes); + let result = hasher.finalize(); + Ok(format!("sha256:{}", hex::encode(result))) + } + + /// Validate a single file against manifest entry. + fn validate_file( + &self, + relative_path: &str, + expected_checksum: &str, + expected_size: u64, + ) -> Result<(), ManifestErrorType> { + let file_path = format!("{}/{}", self.base_path.display(), relative_path); + + // Check if file exists + if !self.vfs.exists(&file_path) { + return Err(ManifestErrorType::FileMissing { + file: relative_path.to_string(), + }); + } + + // Validate checksum format + if !expected_checksum.starts_with("sha256:") { + return Err(ManifestErrorType::InvalidChecksumFormat { + file: relative_path.to_string(), + checksum: expected_checksum.to_string(), + }); + } + + // Read file content for size and checksum validation + let content_bytes = + self.vfs + .read_file(&file_path) + .map_err(|e| ManifestErrorType::FileReadError { + file: relative_path.to_string(), + error_message: format!("{}", e), + })?; + + // Validate file size + let actual_size = content_bytes.len() as u64; + if actual_size != expected_size { + return Err(ManifestErrorType::SizeMismatch { + file: relative_path.to_string(), + expected: expected_size, + actual: actual_size, + }); + } + + // Compute checksum from already-read content + let mut hasher = Sha256::new(); + hasher.update(&content_bytes); + let result = hasher.finalize(); + let actual_checksum = format!("sha256:{}", hex::encode(result)); + + if actual_checksum != expected_checksum { + return Err(ManifestErrorType::ChecksumMismatch { + file: relative_path.to_string(), + expected: expected_checksum.to_string(), + actual: actual_checksum, + }); + } + + Ok(()) + } + + /// Find all files in the policy store (excluding manifest.json). + fn find_all_files(&self) -> Result, PolicyStoreError> { + let mut files = HashSet::new(); + + // Define directories to scan + let dirs = vec![ + "policies", + "templates", + "schemas", + "entities", + "trusted-issuers", + ]; + + for dir in dirs { + let dir_path = format!("{}/{}", self.base_path.display(), dir); + if self.vfs.exists(&dir_path) && self.vfs.is_dir(&dir_path) { + self.scan_directory(&dir_path, dir, &mut files)?; + } + } + + // Add metadata.json if it exists + let metadata_path = format!("{}/metadata.json", self.base_path.display()); + if self.vfs.exists(&metadata_path) { + files.insert("metadata.json".to_string()); + } + + // Add schema.cedarschema if it exists + let schema_path = format!("{}/schema.cedarschema", self.base_path.display()); + if self.vfs.exists(&schema_path) { + files.insert("schema.cedarschema".to_string()); + } + + Ok(files) + } + + /// Recursively scan a directory for files. + fn scan_directory( + &self, + dir_path: &str, + relative_base: &str, + files: &mut HashSet, + ) -> Result<(), PolicyStoreError> { + let entries = + self.vfs + .read_dir(dir_path) + .map_err(|e| PolicyStoreError::DirectoryReadError { + path: dir_path.to_string(), + source: e, + })?; + + for entry in entries { + let path = &entry.path; + let file_name = &entry.name; + + if self.vfs.is_file(path) { + let relative_path = format!("{}/{}", relative_base, file_name); + files.insert(relative_path); + } else if self.vfs.is_dir(path) { + let new_relative_base = format!("{}/{}", relative_base, file_name); + self.scan_directory(path, &new_relative_base, files)?; + } + } + + Ok(()) + } + + /// Validate the entire policy store against the manifest. + pub fn validate(&self, metadata_id: Option<&str>) -> ManifestValidationResult { + let mut result = ManifestValidationResult::new(); + + // Load manifest + let manifest = match self.load_manifest() { + Ok(m) => m, + Err(PolicyStoreError::ManifestError { err }) => { + result.add_error(err, None); + return result; + }, + Err(e) => { + result.add_error( + ManifestErrorType::ParseError(e.to_string()), + Some("manifest.json".to_string()), + ); + return result; + }, + }; + + // Validate policy store ID if metadata is provided + if let Some(metadata_id) = metadata_id + && manifest.policy_store_id != metadata_id + { + result.add_error( + ManifestErrorType::PolicyStoreIdMismatch { + expected: manifest.policy_store_id.clone(), + actual: metadata_id.to_string(), + }, + None, + ); + } + + // Validate each file in manifest + for (file_path, file_info) in &manifest.files { + match self.validate_file(file_path, &file_info.checksum, file_info.size) { + Ok(()) => { + result.add_validated_file(file_path.clone()); + }, + Err(err) => { + result.add_error(err, Some(file_path.clone())); + }, + } + } + + // Find unlisted files (files in policy store but not in manifest) + match self.find_all_files() { + Ok(all_files) => { + let manifest_files: HashSet = manifest.files.keys().cloned().collect(); + for file in all_files { + if !manifest_files.contains(&file) { + result.add_unlisted_file(file); + } + } + }, + Err(e) => { + result.add_error( + ManifestErrorType::ParseError(format!("Failed to scan files: {}", e)), + None, + ); + }, + } + + result + } +} + +#[cfg(test)] +mod tests { + use super::super::metadata::FileInfo; + use super::super::vfs_adapter::MemoryVfs; + use super::*; + use chrono::Utc; + use std::collections::HashMap; + + #[test] + fn test_compute_checksum() { + let vfs = MemoryVfs::new(); + vfs.create_file("/test.txt", b"hello world") + .expect("should create test file"); + + let validator = ManifestValidator::new(vfs, PathBuf::from("/")); + let checksum = validator + .compute_checksum("/test.txt") + .expect("should compute checksum"); + + // Expected SHA-256 of "hello world" + assert_eq!( + checksum, + "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + ); + } + + #[test] + fn test_load_manifest_not_found() { + let vfs = MemoryVfs::new(); + let validator = ManifestValidator::new(vfs, PathBuf::from("/")); + + let result = validator.load_manifest(); + assert!(matches!( + result.expect_err("should fail when manifest not found"), + PolicyStoreError::ManifestError { + err: ManifestErrorType::ManifestNotFound + } + )); + } + + #[test] + fn test_load_manifest_success() { + let vfs = MemoryVfs::new(); + + let manifest_json = r#"{ + "policy_store_id": "test123", + "generated_date": "2024-01-01T12:00:00Z", + "files": { + "metadata.json": { + "size": 100, + "checksum": "sha256:abc123" + } + } + }"#; + + vfs.create_file("/manifest.json", manifest_json.as_bytes()) + .expect("should succeed"); + + let validator = ManifestValidator::new(vfs, PathBuf::from("/")); + let manifest = validator.load_manifest().expect("should succeed"); + + assert_eq!(manifest.policy_store_id, "test123"); + assert_eq!(manifest.files.len(), 1); + } + + #[test] + fn test_validate_file_missing() { + let vfs = MemoryVfs::new(); + let validator = ManifestValidator::new(vfs, PathBuf::from("/")); + + let result = validator.validate_file("missing.txt", "sha256:abc", 100); + let err = result.expect_err("Expected FileMissing error for nonexistent file"); + assert!( + matches!(err, ManifestErrorType::FileMissing { .. }), + "Expected FileMissing error, got: {:?}", + err + ); + } + + #[test] + fn test_validate_file_invalid_checksum_format() { + let vfs = MemoryVfs::new(); + vfs.create_file("/test.txt", b"hello") + .expect("should succeed"); + + let validator = ManifestValidator::new(vfs, PathBuf::from("/")); + let result = validator.validate_file("test.txt", "invalid_format", 5); + + let err = result.expect_err("Expected InvalidChecksumFormat error"); + assert!( + matches!(err, ManifestErrorType::InvalidChecksumFormat { .. }), + "Expected InvalidChecksumFormat error, got: {:?}", + err + ); + } + + #[test] + fn test_validate_file_size_mismatch() { + let vfs = MemoryVfs::new(); + vfs.create_file("/test.txt", b"hello") + .expect("should succeed"); + + let validator = ManifestValidator::new(vfs, PathBuf::from("/")); + let result = validator.validate_file("test.txt", "sha256:abc", 100); // Wrong size + + let err = result.expect_err("Expected SizeMismatch error"); + assert!( + matches!(err, ManifestErrorType::SizeMismatch { .. }), + "Expected SizeMismatch error, got: {:?}", + err + ); + } + + #[test] + fn test_validate_file_checksum_mismatch() { + let vfs = MemoryVfs::new(); + vfs.create_file("/test.txt", b"hello") + .expect("should succeed"); + + let validator = ManifestValidator::new(vfs, PathBuf::from("/")); + let result = validator.validate_file("test.txt", "sha256:wrongchecksum", 5); + + let err = result.expect_err("Expected ChecksumMismatch error"); + assert!( + matches!(err, ManifestErrorType::ChecksumMismatch { .. }), + "Expected ChecksumMismatch error, got: {:?}", + err + ); + } + + #[test] + fn test_validate_file_success() { + let vfs = MemoryVfs::new(); + let content = b"hello world"; + vfs.create_file("/test.txt", content) + .expect("should succeed"); + + let validator = ManifestValidator::new(vfs, PathBuf::from("/")); + + // Compute correct checksum + let checksum = validator + .compute_checksum("/test.txt") + .expect("should succeed"); + + let result = validator.validate_file("test.txt", &checksum, content.len() as u64); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_complete_policy_store_success() { + let vfs = MemoryVfs::new(); + + // Create metadata + let metadata_content = b"{\"test\": \"data\"}"; + vfs.create_file("/metadata.json", metadata_content) + .expect("should succeed"); + + // Create policy + let policy_content = b"permit(principal, action, resource);"; + vfs.create_file("/policies/policy1.cedar", policy_content) + .expect("should succeed"); + + let validator = ManifestValidator::new(vfs, PathBuf::from("/")); + + // Compute checksums + let metadata_checksum = validator + .compute_checksum("/metadata.json") + .expect("should succeed"); + let policy_checksum = validator + .compute_checksum("/policies/policy1.cedar") + .expect("should succeed"); + + // Create manifest + let mut files = HashMap::new(); + files.insert( + "metadata.json".to_string(), + FileInfo { + size: metadata_content.len() as u64, + checksum: metadata_checksum, + }, + ); + files.insert( + "policies/policy1.cedar".to_string(), + FileInfo { + size: policy_content.len() as u64, + checksum: policy_checksum, + }, + ); + + let manifest = PolicyStoreManifest { + policy_store_id: "test123".to_string(), + generated_date: Utc::now(), + files, + }; + + let manifest_json = serde_json::to_string(&manifest).expect("should succeed"); + validator + .vfs + .create_file("/manifest.json", manifest_json.as_bytes()) + .expect("should succeed"); + + // Validate + let result = validator.validate(Some("test123")); + assert!(result.is_valid); + assert_eq!(result.validated_files.len(), 2); + assert_eq!(result.errors.len(), 0); + } + + #[test] + fn test_validate_with_unlisted_files() { + let vfs = MemoryVfs::new(); + + // Create files + let metadata_content = b"{\"test\": \"data\"}"; + let policy_content = b"permit(principal, action, resource);"; + + vfs.create_file("/metadata.json", metadata_content) + .expect("should succeed"); + vfs.create_file("/policies/policy1.cedar", policy_content) + .expect("should succeed"); + vfs.create_file("/policies/extra_policy.cedar", policy_content) + .expect("should succeed"); + + let validator = ManifestValidator::new(vfs, PathBuf::from("/")); + + // Create manifest with only metadata.json and policy1.cedar + let metadata_checksum = validator + .compute_checksum("/metadata.json") + .expect("should succeed"); + let policy_checksum = validator + .compute_checksum("/policies/policy1.cedar") + .expect("should succeed"); + + let mut files = HashMap::new(); + files.insert( + "metadata.json".to_string(), + FileInfo { + size: metadata_content.len() as u64, + checksum: metadata_checksum, + }, + ); + files.insert( + "policies/policy1.cedar".to_string(), + FileInfo { + size: policy_content.len() as u64, + checksum: policy_checksum, + }, + ); + + let manifest = PolicyStoreManifest { + policy_store_id: "test123".to_string(), + generated_date: Utc::now(), + files, + }; + + let manifest_json = serde_json::to_string(&manifest).expect("should succeed"); + validator + .vfs + .create_file("/manifest.json", manifest_json.as_bytes()) + .expect("should succeed"); + + // Validate + let result = validator.validate(None); + + assert!(result.is_valid); // Still valid, but with warnings + assert_eq!(result.validated_files.len(), 2); + assert_eq!(result.unlisted_files.len(), 1); + assert!( + result + .unlisted_files + .contains(&"policies/extra_policy.cedar".to_string()) + ); + } + + #[test] + fn test_validate_policy_store_id_mismatch() { + let vfs = MemoryVfs::new(); + + let manifest = PolicyStoreManifest { + policy_store_id: "expected_id".to_string(), + generated_date: Utc::now(), + files: HashMap::new(), + }; + + let manifest_json = serde_json::to_string(&manifest).expect("should succeed"); + vfs.create_file("/manifest.json", manifest_json.as_bytes()) + .expect("should succeed"); + + let validator = ManifestValidator::new(vfs, PathBuf::from("/")); + let result = validator.validate(Some("wrong_id")); + + assert!(!result.is_valid); + assert!(result.errors.iter().any(|e| matches!( + &e.error_type, + ManifestErrorType::PolicyStoreIdMismatch { .. } + ))); + } + + #[test] + fn test_validate_with_missing_file() { + let vfs = MemoryVfs::new(); + + // Create manifest with a file that doesn't exist + let mut files = HashMap::new(); + files.insert( + "missing.txt".to_string(), + FileInfo { + size: 100, + checksum: "sha256:abc123".to_string(), + }, + ); + + let manifest = PolicyStoreManifest { + policy_store_id: "test123".to_string(), + generated_date: Utc::now(), + files, + }; + + let manifest_json = serde_json::to_string(&manifest).expect("should succeed"); + vfs.create_file("/manifest.json", manifest_json.as_bytes()) + .expect("should succeed"); + + let validator = ManifestValidator::new(vfs, PathBuf::from("/")); + let result = validator.validate(None); + + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| matches!(&e.error_type, ManifestErrorType::FileMissing { .. })) + ); + } + + #[test] + fn test_validate_with_checksum_mismatch() { + let vfs = MemoryVfs::new(); + + vfs.create_file("/test.txt", b"actual content") + .expect("should succeed"); + + // Create manifest with wrong checksum + let mut files = HashMap::new(); + files.insert( + "test.txt".to_string(), + FileInfo { + size: 14, + checksum: "sha256:wrongchecksum".to_string(), + }, + ); + + let manifest = PolicyStoreManifest { + policy_store_id: "test123".to_string(), + generated_date: Utc::now(), + files, + }; + + let manifest_json = serde_json::to_string(&manifest).expect("should succeed"); + vfs.create_file("/manifest.json", manifest_json.as_bytes()) + .expect("should succeed"); + + let validator = ManifestValidator::new(vfs, PathBuf::from("/")); + let result = validator.validate(None); + + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| matches!(&e.error_type, ManifestErrorType::ChecksumMismatch { .. })) + ); + } +} diff --git a/jans-cedarling/cedarling/src/common/policy_store/metadata.rs b/jans-cedarling/cedarling/src/common/policy_store/metadata.rs new file mode 100644 index 00000000000..5639b313965 --- /dev/null +++ b/jans-cedarling/cedarling/src/common/policy_store/metadata.rs @@ -0,0 +1,192 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Policy store metadata types for identification, versioning, and integrity validation. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Helper module for serializing Optional DateTime +mod datetime_option { + use chrono::{DateTime, Utc}; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(date: &Option>, serializer: S) -> Result + where + S: Serializer, + { + match date { + Some(dt) => serializer.serialize_some(&dt.to_rfc3339()), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + let opt: Option = Option::deserialize(deserializer)?; + match opt { + Some(s) => DateTime::parse_from_rfc3339(&s) + .map(|dt| Some(dt.with_timezone(&Utc))) + .map_err(serde::de::Error::custom), + None => Ok(None), + } + } +} + +/// Helper module for serializing DateTime +mod datetime { + use chrono::{DateTime, Utc}; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(date: &DateTime, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&date.to_rfc3339()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s: String = String::deserialize(deserializer)?; + DateTime::parse_from_rfc3339(&s) + .map(|dt| dt.with_timezone(&Utc)) + .map_err(serde::de::Error::custom) + } +} + +/// Metadata for a policy store. +/// +/// Contains identification, versioning, and descriptive information about a policy store. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PolicyStoreMetadata { + /// The version of the Cedar policy language used in this policy store + pub cedar_version: String, + /// Policy store configuration + pub policy_store: PolicyStoreInfo, +} + +/// Core information about a policy store. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PolicyStoreInfo { + /// Unique identifier for the policy store (hex hash) + #[serde(default)] + pub id: String, + /// Human-readable name for the policy store + pub name: String, + /// Optional description of the policy store + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Semantic version of the policy store content + #[serde(default)] + pub version: String, + /// ISO 8601 timestamp when created + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "datetime_option" + )] + pub created_date: Option>, + /// ISO 8601 timestamp when last modified + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "datetime_option" + )] + pub updated_date: Option>, +} + +/// Manifest file for policy store integrity validation. +/// +/// Contains checksums and metadata for all files in the policy store. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PolicyStoreManifest { + /// Reference to the policy store ID this manifest belongs to + pub policy_store_id: String, + /// ISO 8601 timestamp when the manifest was generated + #[serde(with = "datetime")] + pub generated_date: DateTime, + /// Map of file paths to their metadata + pub files: HashMap, +} + +/// Information about a file in the policy store manifest. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FileInfo { + /// File size in bytes + pub size: u64, + /// SHA-256 checksum of the file content (format: "sha256:") + pub checksum: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_policy_store_metadata_serialization() { + // Use a fixed timestamp for deterministic comparison + let created = DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + let updated = DateTime::parse_from_rfc3339("2024-01-02T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + + let metadata = PolicyStoreMetadata { + cedar_version: "4.4.0".to_string(), + policy_store: PolicyStoreInfo { + id: "abc123".to_string(), + name: "test_store".to_string(), + description: Some("A test policy store".to_string()), + version: "1.0.0".to_string(), + created_date: Some(created), + updated_date: Some(updated), + }, + }; + + // Test serialization + let json = serde_json::to_string(&metadata).unwrap(); + assert!(json.contains("cedar_version")); + assert!(json.contains("4.4.0")); + + // Test deserialization - compare whole structure + let deserialized: PolicyStoreMetadata = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, metadata); + } + + #[test] + fn test_policy_store_manifest_serialization() { + // Use a fixed timestamp for deterministic comparison + let generated = DateTime::parse_from_rfc3339("2024-01-01T12:00:00Z") + .unwrap() + .with_timezone(&Utc); + + let mut files = HashMap::new(); + files.insert("metadata.json".to_string(), FileInfo { + size: 245, + checksum: "sha256:abc123".to_string(), + }); + + let manifest = PolicyStoreManifest { + policy_store_id: "test123".to_string(), + generated_date: generated, + files, + }; + + // Test serialization + let json = serde_json::to_string(&manifest).unwrap(); + assert!(json.contains("policy_store_id")); + assert!(json.contains("test123")); + + // Test deserialization - compare whole structure + let deserialized: PolicyStoreManifest = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, manifest); + } +} diff --git a/jans-cedarling/cedarling/src/common/policy_store/policy_parser.rs b/jans-cedarling/cedarling/src/common/policy_store/policy_parser.rs new file mode 100644 index 00000000000..0feface2f87 --- /dev/null +++ b/jans-cedarling/cedarling/src/common/policy_store/policy_parser.rs @@ -0,0 +1,495 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Cedar policy and template parsing and validation. +//! +//! This module handles parsing Cedar policy files (.cedar) and extracting +//! policy IDs from @id() annotations. It provides validation and error +//! reporting with file names and line numbers. + +use cedar_policy::{Policy, PolicyId, PolicySet, Template}; +#[cfg(test)] +use std::collections::HashMap; + +use super::errors::{CedarParseErrorDetail, PolicyStoreError, ValidationError}; + +/// Represents a parsed Cedar policy with metadata. +#[derive(Debug, Clone)] +pub struct ParsedPolicy { + /// The policy ID (from Cedar engine or @id annotation) + pub id: PolicyId, + /// The original filename + pub filename: String, + /// The parsed Cedar policy + pub policy: Policy, +} + +/// Represents a parsed Cedar template with metadata. +#[derive(Debug, Clone)] +pub struct ParsedTemplate { + /// The original filename + pub filename: String, + /// The parsed Cedar template + pub template: Template, +} + +/// Cedar policy and template parser. +/// +/// Provides methods for parsing Cedar policies and templates from text, +/// extracting @id() annotations, and validating syntax. +pub struct PolicyParser; + +impl PolicyParser { + /// Parse a single policy from Cedar policy text. + /// + /// The policy ID is determined by: + /// 1. Extracting from @id() annotation in the policy text, OR + /// 2. Deriving from the filename (without .cedar extension) + /// + /// Pass the ID to `Policy::parse()` using the annotation or the filename (without + /// the .cedar extension). + pub fn parse_policy(content: &str, filename: &str) -> Result { + // Extract policy ID from @id() annotation or derive from filename + let policy_id_str = Self::extract_id_annotation(content) + .or_else(|| Self::derive_id_from_filename(filename)); + + let policy_id = match policy_id_str { + Some(id_str) => { + // Validate the ID format + Self::validate_policy_id(&id_str, filename) + .map_err(PolicyStoreError::Validation)?; + PolicyId::new(&id_str) + }, + None => { + return Err(PolicyStoreError::CedarParsing { + file: filename.to_string(), + detail: CedarParseErrorDetail::MissingIdAnnotation, + }); + }, + }; + + // Parse the policy using Cedar engine with the policy ID + let policy = Policy::parse(Some(policy_id.clone()), content).map_err(|e| { + PolicyStoreError::CedarParsing { + file: filename.to_string(), + detail: CedarParseErrorDetail::ParseError(e.to_string()), + } + })?; + + Ok(ParsedPolicy { + id: policy_id, + filename: filename.to_string(), + policy, + }) + } + + /// Parse a single template from Cedar policy text. + /// + /// Templates support slots (e.g., ?principal) and are parsed similarly to policies. + /// The template ID is extracted from @id() annotation or derived from filename. + /// + /// the ID to `Template::parse()` based on annotation or filename. + pub fn parse_template( + content: &str, + filename: &str, + ) -> Result { + // Extract template ID from @id() annotation or derive from filename + let template_id_str = Self::extract_id_annotation(content) + .or_else(|| Self::derive_id_from_filename(filename)); + + let template_id = match template_id_str { + Some(id_str) => { + // Validate the ID format + Self::validate_policy_id(&id_str, filename) + .map_err(PolicyStoreError::Validation)?; + PolicyId::new(&id_str) + }, + None => { + return Err(PolicyStoreError::CedarParsing { + file: filename.to_string(), + detail: CedarParseErrorDetail::MissingIdAnnotation, + }); + }, + }; + + // Parse the template using Cedar engine with the template ID + let template = Template::parse(Some(template_id.clone()), content).map_err(|e| { + PolicyStoreError::CedarParsing { + file: filename.to_string(), + detail: CedarParseErrorDetail::ParseError(e.to_string()), + } + })?; + + Ok(ParsedTemplate { + filename: filename.to_string(), + template, + }) + } + + /// Parse multiple policies and return a map of policy ID to filename. + /// + /// Useful for batch processing of policy files in tests and tooling. + #[cfg(test)] + pub fn parse_policies<'a, I>( + policy_files: I, + ) -> Result, PolicyStoreError> + where + I: IntoIterator, + { + let policy_files_vec: Vec<_> = policy_files.into_iter().collect(); + let mut policy_map = HashMap::with_capacity(policy_files_vec.len()); + + for (filename, content) in policy_files_vec { + let parsed = Self::parse_policy(content, filename)?; + policy_map.insert(parsed.id, parsed.filename); + } + + Ok(policy_map) + } + + /// Create a PolicySet from parsed policies and templates. + /// + /// Validates that all policies and templates can be successfully added + /// to the policy set, ensuring no ID conflicts or other issues. + pub fn create_policy_set( + policies: Vec, + templates: Vec, + ) -> Result { + let mut policy_set = PolicySet::new(); + + // Add all policies + for parsed in policies { + policy_set + .add(parsed.policy) + .map_err(|e| PolicyStoreError::CedarParsing { + file: parsed.filename, + detail: CedarParseErrorDetail::AddPolicyFailed(e.to_string()), + })?; + } + + // Add all templates + for parsed in templates { + policy_set.add_template(parsed.template).map_err(|e| { + PolicyStoreError::CedarParsing { + file: parsed.filename, + detail: CedarParseErrorDetail::AddTemplateFailed(e.to_string()), + } + })?; + } + + Ok(policy_set) + } + + /// Derive a policy ID from a filename. + /// + /// Removes the .cedar extension, sanitizes characters, and returns the ID. + /// Returns None if the filename is empty or invalid. + pub fn derive_id_from_filename(filename: &str) -> Option { + // Extract just the filename without path + let base_name = filename.rsplit('/').next().unwrap_or(filename); + + // Remove .cedar extension + let without_ext = base_name.strip_suffix(".cedar").unwrap_or(base_name); + + // If empty after stripping, return None + if without_ext.is_empty() { + return None; + } + + // Replace invalid characters with underscores + let sanitized: String = without_ext + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '_' || c == '-' || c == ':' { + c + } else { + '_' + } + }) + .collect(); + + Some(sanitized) + } + + /// Extract @id() annotation from Cedar policy text. + /// + /// Looks for @id("...") or @id('...') pattern in comments. + pub fn extract_id_annotation(content: &str) -> Option { + // Look for @id("...") or @id('...') pattern + for line in content.lines() { + let trimmed = line.trim(); + if let Some(start_idx) = trimmed.find("@id(") { + let after_id = &trimmed[start_idx + 4..]; + // Find the string content between quotes + if let Some(open_quote) = after_id.find('"').or_else(|| after_id.find('\'')) { + let quote_char = after_id.chars().nth(open_quote).unwrap(); + let after_open = &after_id[open_quote + 1..]; + if let Some(close_quote) = after_open.find(quote_char) { + return Some(after_open[..close_quote].to_string()); + } + } + } + } + None + } + + /// Validate policy ID format (alphanumeric, underscore, hyphen, colon only). + pub fn validate_policy_id(id: &str, filename: &str) -> Result<(), ValidationError> { + if id.is_empty() { + return Err(ValidationError::EmptyPolicyId { + file: filename.to_string(), + }); + } + + // Check for valid characters (alphanumeric, underscore, hyphen, colon) + if !id + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == ':') + { + return Err(ValidationError::InvalidPolicyIdCharacters { + file: filename.to_string(), + id: id.to_string(), + }); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn test_parse_simple_policy() { + let policy_text = r#" + permit( + principal == User::"alice", + action == Action::"view", + resource == File::"report.txt" + ); + "#; + + let result = PolicyParser::parse_policy(policy_text, "test.cedar"); + assert!(result.is_ok()); + + let parsed = result.unwrap(); + assert_eq!(parsed.filename, "test.cedar"); + // ID should be derived from filename + assert_eq!(parsed.id.to_string(), "test"); + } + + #[test] + fn test_parse_invalid_policy() { + let policy_text = "this is not valid cedar syntax"; + + let result = PolicyParser::parse_policy(policy_text, "invalid.cedar"); + let err = result.expect_err("Expected CedarParsing error for invalid syntax"); + + assert!( + matches!( + &err, + PolicyStoreError::CedarParsing { file, detail: CedarParseErrorDetail::ParseError(_) } + if file == "invalid.cedar" + ), + "Expected CedarParsing error with ParseError detail, got: {:?}", + err + ); + } + + #[test] + fn test_parse_template() { + let template_text = r#" + permit( + principal == ?principal, + action == Action::"view", + resource == File::"report.txt" + ); + "#; + + let result = PolicyParser::parse_template(template_text, "template.cedar"); + assert!(result.is_ok()); + + let parsed = result.unwrap(); + assert_eq!(parsed.filename, "template.cedar"); + // ID should be derived from filename - get from template directly + assert_eq!(parsed.template.id().to_string(), "template"); + } + + #[test] + fn test_parse_multiple_policies() { + let policy1 = r#" + permit( + principal == User::"alice", + action == Action::"view", + resource == File::"doc1.txt" + ); + "#; + + let policy2 = r#" + permit( + principal == User::"bob", + action == Action::"edit", + resource == File::"doc2.txt" + ); + "#; + + let files = vec![("policy1.cedar", policy1), ("policy2.cedar", policy2)]; + + let result = PolicyParser::parse_policies(files); + assert!(result.is_ok()); + + let policy_map = result.unwrap(); + + assert!(!policy_map.is_empty()); + } + + #[test] + fn test_extract_id_annotation_double_quotes() { + let policy_text = r#" + // @id("my-policy-id") + permit( + principal == User::"alice", + action == Action::"view", + resource == File::"report.txt" + ); + "#; + + let id = PolicyParser::extract_id_annotation(policy_text); + assert_eq!(id, Some("my-policy-id".to_string())); + } + + #[test] + fn test_extract_id_annotation_single_quotes() { + let policy_text = r#" + // @id('another-policy-id') + permit(principal, action, resource); + "#; + + let id = PolicyParser::extract_id_annotation(policy_text); + assert_eq!(id, Some("another-policy-id".to_string())); + } + + #[test] + fn test_extract_id_annotation_not_found() { + let policy_text = r#" + permit(principal, action, resource); + "#; + + let id = PolicyParser::extract_id_annotation(policy_text); + assert_eq!(id, None); + } + + #[test] + fn test_validate_policy_id_valid() { + let result = PolicyParser::validate_policy_id("valid_policy-id:123", "test.cedar"); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_policy_id_empty() { + let result = PolicyParser::validate_policy_id("", "test.cedar"); + let err = result.expect_err("Expected EmptyPolicyId error for empty policy ID"); + assert!( + matches!(err, ValidationError::EmptyPolicyId { .. }), + "Expected EmptyPolicyId error, got: {:?}", + err + ); + } + + #[test] + fn test_validate_policy_id_invalid_chars() { + let result = PolicyParser::validate_policy_id("invalid@policy#id", "test.cedar"); + let err = result.expect_err("Expected InvalidPolicyIdCharacters error for invalid chars"); + assert!( + matches!(err, ValidationError::InvalidPolicyIdCharacters { .. }), + "Expected InvalidPolicyIdCharacters error, got: {:?}", + err + ); + } + + #[test] + fn test_create_policy_set() { + // When parsing a single permit and forbid, they get different content so different IDs + let combined_text = r#" + permit(principal == User::"alice", action, resource); + forbid(principal == User::"bob", action, resource); + "#; + + // Parse as a set to get unique IDs + let policy_set = PolicySet::from_str(combined_text).unwrap(); + let policies: Vec = policy_set + .policies() + .map(|p| ParsedPolicy { + id: p.id().clone(), + filename: "test.cedar".to_string(), + policy: p.clone(), + }) + .collect(); + + let result = PolicyParser::create_policy_set(policies, vec![]); + assert!(result.is_ok()); + + let policy_set = result.unwrap(); + assert!(!policy_set.is_empty()); + } + + #[test] + fn test_create_policy_set_with_template() { + let policy_text = r#"permit(principal, action, resource);"#; + let template_text = r#"permit(principal == ?principal, action, resource);"#; + + let parsed_policy = PolicyParser::parse_policy(policy_text, "policy.cedar").unwrap(); + let parsed_template = + PolicyParser::parse_template(template_text, "template.cedar").unwrap(); + + // Verify IDs are derived from filenames + assert_eq!(parsed_policy.id.to_string(), "policy"); + assert_eq!(parsed_template.template.id().to_string(), "template"); + + let result = PolicyParser::create_policy_set(vec![parsed_policy], vec![parsed_template]); + assert!(result.is_ok()); + + let policy_set = result.unwrap(); + assert!(!policy_set.is_empty()); + } + + #[test] + fn test_derive_id_from_filename() { + assert_eq!( + PolicyParser::derive_id_from_filename("my-policy.cedar"), + Some("my-policy".to_string()) + ); + assert_eq!( + PolicyParser::derive_id_from_filename("/path/to/policy.cedar"), + Some("policy".to_string()) + ); + assert_eq!( + PolicyParser::derive_id_from_filename("policy with spaces.cedar"), + Some("policy_with_spaces".to_string()) + ); + assert_eq!(PolicyParser::derive_id_from_filename(".cedar"), None); + } + + #[test] + fn test_parse_policy_with_id_annotation() { + let policy_text = r#" + // @id("custom-policy-id") + permit( + principal == User::"alice", + action == Action::"view", + resource == File::"report.txt" + ); + "#; + + let result = PolicyParser::parse_policy(policy_text, "ignored.cedar"); + assert!(result.is_ok()); + + let parsed = result.unwrap(); + // ID should come from @id annotation, not filename + assert_eq!(parsed.id.to_string(), "custom-policy-id"); + } +} diff --git a/jans-cedarling/cedarling/src/common/policy_store/schema_parser.rs b/jans-cedarling/cedarling/src/common/policy_store/schema_parser.rs new file mode 100644 index 00000000000..b9145a60def --- /dev/null +++ b/jans-cedarling/cedarling/src/common/policy_store/schema_parser.rs @@ -0,0 +1,518 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Cedar schema parsing and validation. +//! +//! This module provides functionality to parse and validate Cedar schema files, +//! ensuring they are syntactically correct and semantically valid before being +//! used for policy validation and evaluation. + +use cedar_policy::{Schema, SchemaFragment}; +use std::str::FromStr; + +use super::errors::{CedarSchemaErrorType, PolicyStoreError}; + +/// A parsed and validated Cedar schema. +/// +/// Contains the schema and metadata about the source file. +#[derive(Debug, Clone)] +pub struct ParsedSchema { + /// The Cedar schema + pub schema: Schema, + /// Source filename + pub filename: String, + /// Raw schema content + pub content: String, +} + +impl ParsedSchema { + /// Parse a Cedar schema from a string. + /// + /// Parses the schema content using Cedar's schema parser and returns + /// a `ParsedSchema` with metadata. The schema is validated for correct + /// syntax and structure during parsing. + pub fn parse(content: &str, filename: &str) -> Result { + // Parse the schema using Cedar's schema parser + // Cedar uses SchemaFragment to parse human-readable schema syntax + let fragment = + SchemaFragment::from_str(content).map_err(|e| PolicyStoreError::CedarSchemaError { + file: filename.to_string(), + err: CedarSchemaErrorType::ParseError(e.to_string()), + })?; + + // Create schema from the fragment + let schema = Schema::from_schema_fragments([fragment]).map_err(|e| { + PolicyStoreError::CedarSchemaError { + file: filename.to_string(), + err: CedarSchemaErrorType::ValidationError(e.to_string()), + } + })?; + + Ok(Self { + schema, + filename: filename.to_string(), + content: content.to_string(), + }) + } + + /// Get a reference to the Cedar Schema. + /// + /// Returns the validated Cedar Schema that can be used for policy validation. + pub fn get_schema(&self) -> &Schema { + &self.schema + } + + /// Validate that the schema is non-empty and well-formed. + /// + /// Performs additional validation checks beyond basic parsing to ensure + /// the schema is not empty. If parsing succeeded, the schema is already + /// validated by Cedar for internal consistency. + /// + /// # Errors + /// Returns `PolicyStoreError::CedarSchemaError` if the schema file is empty. + pub fn validate(&self) -> Result<(), PolicyStoreError> { + // Check that content is not empty + if self.content.trim().is_empty() { + return Err(PolicyStoreError::CedarSchemaError { + file: self.filename.clone(), + err: CedarSchemaErrorType::EmptySchema, + }); + } + + // If parsing succeeded, the schema is already validated by Cedar + // The Schema type guarantees internal consistency + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_valid_schema() { + let content = r#" + namespace TestApp { + entity User; + entity File; + action "view" appliesTo { + principal: [User], + resource: [File] + }; + } + "#; + + let result = ParsedSchema::parse(content, "test.cedarschema"); + assert!(result.is_ok()); + + let parsed = result.unwrap(); + assert_eq!(parsed.filename, "test.cedarschema"); + assert_eq!(parsed.content, content); + } + + #[test] + fn test_parse_schema_with_multiple_namespaces() { + let content = r#" + namespace App1 { + entity User; + } + + namespace App2 { + entity Admin; + } + "#; + + let result = ParsedSchema::parse(content, "multi.cedarschema"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_schema_with_complex_types() { + let content = r#" + namespace MyApp { + entity User = { + "name": String, + "age": Long, + "email": String + }; + + entity Document = { + "title": String, + "owner": User, + "tags": Set + }; + + action "view" appliesTo { + principal: [User], + resource: [Document] + }; + + action "edit" appliesTo { + principal: [User], + resource: [Document] + }; + } + "#; + + let result = ParsedSchema::parse(content, "complex.cedarschema"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_invalid_schema_syntax() { + let content = "this is not valid cedar schema syntax!!!"; + + let result = ParsedSchema::parse(content, "invalid.cedarschema"); + let err = result.expect_err("Expected CedarSchemaError for invalid syntax"); + + assert!( + matches!( + &err, + PolicyStoreError::CedarSchemaError { file, err: CedarSchemaErrorType::ParseError(_) } + if file == "invalid.cedarschema" + ), + "Expected CedarSchemaError with ParseError, got: {:?}", + err + ); + } + + #[test] + fn test_parse_empty_schema() { + let content = ""; + + let result = ParsedSchema::parse(content, "empty.cedarschema"); + // Empty schema is actually valid in Cedar, but our validation will catch it + if let Ok(parsed) = result { + let validation = parsed.validate(); + let err = validation.expect_err("Expected EmptySchema validation error"); + assert!( + matches!( + &err, + PolicyStoreError::CedarSchemaError { + err: CedarSchemaErrorType::EmptySchema, + .. + } + ), + "Expected EmptySchema error, got: {:?}", + err + ); + } + } + + #[test] + fn test_parse_schema_missing_closing_brace() { + let content = r#" + namespace MyApp { + entity User; + entity File; + "#; + + let result = ParsedSchema::parse(content, "malformed.cedarschema"); + let err = result.expect_err("Expected error for missing closing brace"); + assert!( + matches!(&err, PolicyStoreError::CedarSchemaError { .. }), + "Expected CedarSchemaError for malformed schema, got: {:?}", + err + ); + } + + #[test] + fn test_validate_schema_success() { + let content = r#" + namespace TestApp { + entity User; + } + "#; + + let parsed = ParsedSchema::parse(content, "test.cedarschema").unwrap(); + let result = parsed.validate(); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_schema_with_entity_hierarchy() { + let content = r#" + namespace OrgApp { + entity User in [Group]; + entity Group in [Organization]; + entity Organization; + + action "view" appliesTo { + principal: [User, Group], + resource: [User, Group, Organization] + }; + } + "#; + + let result = ParsedSchema::parse(content, "hierarchy.cedarschema"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_schema_with_action_groups() { + let content = r#" + namespace FileSystem { + entity User; + entity File; + + action "read" appliesTo { + principal: [User], + resource: [File] + }; + + action "write" appliesTo { + principal: [User], + resource: [File] + }; + + action "readWrite" in ["read", "write"]; + } + "#; + + let result = ParsedSchema::parse(content, "action_groups.cedarschema"); + assert!(result.is_ok()); + } + + #[test] + fn test_schema_error_message_includes_filename() { + let content = "namespace { invalid }"; + + let result = ParsedSchema::parse(content, "my_schema.cedarschema"); + let err = result.expect_err("Expected error for invalid namespace syntax"); + assert!( + matches!(&err, PolicyStoreError::CedarSchemaError { file, .. } if file == "my_schema.cedarschema"), + "Expected CedarSchemaError with filename my_schema.cedarschema, got: {:?}", + err + ); + } + + #[test] + fn test_validate_empty_schema_fails() { + let content = " \n \t \n "; + + let result = ParsedSchema::parse(content, "whitespace.cedarschema"); + // Empty content might parse successfully, but validation should fail + if let Ok(parsed) = result { + let validation = parsed.validate(); + assert!( + validation.is_err(), + "Validation should fail for whitespace-only schema" + ); + + let Err(PolicyStoreError::CedarSchemaError { file, err }) = validation else { + panic!("Expected CedarSchemaError"); + }; + + assert_eq!(file, "whitespace.cedarschema"); + assert!(matches!(err, CedarSchemaErrorType::EmptySchema)); + } + } + + #[test] + fn test_parse_schema_with_common_types() { + let content = r#" + namespace AppSchema { + entity User = { + "id": String, + "email": String, + "roles": Set, + "age": Long, + "active": Bool + }; + + entity Resource = { + "name": String, + "owner": User, + "tags": Set + }; + } + "#; + + let result = ParsedSchema::parse(content, "types.cedarschema"); + assert!(result.is_ok(), "Schema with common types should parse"); + } + + #[test] + fn test_parse_schema_with_context() { + let content = r#" + namespace ContextApp { + entity User; + entity File; + + action "view" appliesTo { + principal: [User], + resource: [File], + context: { + "ip_address": String, + "time": Long + } + }; + } + "#; + + let result = ParsedSchema::parse(content, "context.cedarschema"); + assert!(result.is_ok(), "Schema with action context should parse"); + } + + #[test] + fn test_parse_schema_with_optional_attributes() { + let content = r#" + namespace OptionalApp { + entity User = { + "name": String, + "email"?: String, + "phone"?: String + }; + } + "#; + + let result = ParsedSchema::parse(content, "optional.cedarschema"); + assert!( + result.is_ok(), + "Schema with optional attributes should parse" + ); + } + + #[test] + fn test_parse_schema_invalid_entity_definition() { + let content = r#" + namespace MyApp { + entity User = { + "name": InvalidType + }; + } + "#; + + let result = ParsedSchema::parse(content, "invalid_type.cedarschema"); + let err = result.expect_err("Invalid entity type should fail parsing"); + assert!( + matches!(&err, PolicyStoreError::CedarSchemaError { .. }), + "Expected CedarSchemaError for invalid entity type, got: {:?}", + err + ); + } + + #[test] + fn test_parse_schema_missing_semicolon() { + let content = r#" + namespace MyApp { + entity User + entity File; + } + "#; + + let result = ParsedSchema::parse(content, "missing_semicolon.cedarschema"); + let err = result.expect_err("Missing semicolon should fail parsing"); + assert!( + matches!(&err, PolicyStoreError::CedarSchemaError { .. }), + "Expected CedarSchemaError for missing semicolon, got: {:?}", + err + ); + } + + #[test] + fn test_parse_schema_duplicate_entity() { + let content = r#" + namespace MyApp { + entity User; + entity User; + } + "#; + + let result = ParsedSchema::parse(content, "duplicate.cedarschema"); + // Cedar may or may not allow duplicate entity definitions + // This test documents the current behavior - if an error occurs, it should be a schema error + if let Err(err) = result { + assert!( + matches!(&err, PolicyStoreError::CedarSchemaError { .. }), + "Expected CedarSchemaError for duplicate entity, got: {:?}", + err + ); + } + } + + #[test] + fn test_parsed_schema_clone() { + let content = r#" + namespace TestApp { + entity User; + } + "#; + + let parsed = ParsedSchema::parse(content, "test.cedarschema").unwrap(); + let cloned = parsed.clone(); + + assert_eq!(parsed.filename, cloned.filename); + assert_eq!(parsed.content, cloned.content); + } + + #[test] + fn test_parse_schema_with_extension() { + let content = r#" + namespace ExtApp { + entity User; + entity AdminUser in [User]; + entity SuperAdmin in [AdminUser]; + } + "#; + + let result = ParsedSchema::parse(content, "extension.cedarschema"); + assert!( + result.is_ok(), + "Schema with entity hierarchy should parse successfully" + ); + } + + #[test] + fn test_format_schema_error_not_empty() { + // Create an intentionally malformed schema to trigger SchemaError + let content = "namespace MyApp { entity User = { invalid } }"; + + let result = ParsedSchema::parse(content, "test.cedarschema"); + let err = result.expect_err("Expected error for malformed schema"); + assert!( + matches!(&err, PolicyStoreError::CedarSchemaError { file, .. } if file == "test.cedarschema"), + "Expected CedarSchemaError with filename test.cedarschema, got: {:?}", + err + ); + } + + #[test] + fn test_parse_schema_preserves_content() { + let content = r#"namespace Test { entity User; }"#; + + let parsed = ParsedSchema::parse(content, "preserve.cedarschema").unwrap(); + assert_eq!( + parsed.content, content, + "Original content should be preserved" + ); + } + + #[test] + fn test_parse_multiple_schemas_independently() { + let schema1 = r#" + namespace App1 { + entity User; + } + "#; + let schema2 = r#" + namespace App2 { + entity Admin; + } + "#; + + let result1 = ParsedSchema::parse(schema1, "schema1.cedarschema"); + let result2 = ParsedSchema::parse(schema2, "schema2.cedarschema"); + + assert!(result1.is_ok()); + assert!(result2.is_ok()); + + let parsed1 = result1.unwrap(); + let parsed2 = result2.unwrap(); + + assert_ne!(parsed1.filename, parsed2.filename); + assert_ne!(parsed1.content, parsed2.content); + } +} diff --git a/jans-cedarling/cedarling/src/common/policy_store/test.rs b/jans-cedarling/cedarling/src/common/policy_store/test.rs index cca6f7c4440..e1fd7cc948d 100644 --- a/jans-cedarling/cedarling/src/common/policy_store/test.rs +++ b/jans-cedarling/cedarling/src/common/policy_store/test.rs @@ -86,11 +86,13 @@ fn test_base64_decoding_error_in_policy_store() { }); let policy_result = serde_json::from_str::(policy_store_json.to_string().as_str()); + let err = + policy_result.expect_err("Expected base64 decoding error for invalid base64 character"); assert!( - policy_result - .unwrap_err() - .to_string() - .contains(&ParsePolicySetMessage::Base64.to_string()) + err.to_string() + .contains(&ParsePolicySetMessage::Base64.to_string()), + "Error message should indicate base64 decoding failure, got: {}", + err ); } @@ -138,11 +140,12 @@ fn test_policy_parsing_error_in_policy_store() { }); let policy_result = serde_json::from_str::(policy_store_json.to_string().as_str()); + let err = policy_result.expect_err("Expected UTF-8 parsing error for invalid byte sequence"); assert!( - policy_result - .unwrap_err() - .to_string() - .contains(&ParsePolicySetMessage::String.to_string()) + err.to_string() + .contains(&ParsePolicySetMessage::String.to_string()), + "Error message should indicate string parsing failure, got: {}", + err ); } @@ -153,20 +156,21 @@ fn test_broken_policy_parsing_error_in_policy_store() { include_str!("../../../../test_files/policy-store_policy_err_broken_policy.yaml"); let policy_result = serde_yml::from_str::(POLICY_STORE_RAW_YAML); - let err_msg = policy_result.unwrap_err().to_string(); + let err = policy_result.expect_err("Expected policy parsing error for broken policy syntax"); + let err_msg = err.to_string(); assert!( err_msg.contains( "unable to decode policy with id: 840da5d85403f35ea76519ed1a18a33989f855bf1cf8" ), - "actual error: {}", + "Error should identify the policy ID that failed to decode, got: {}", err_msg ); assert!( err_msg.contains( "unable to decode policy_content from human readable format: this policy is missing the `resource` variable in the scope" ), - "actual error: {}", + "Error should describe the syntax error, got: {}", err_msg ); } @@ -189,21 +193,39 @@ fn test_valid_version_with_v() { #[test] fn test_invalid_version_format() { let invalid_version = "1.2".to_string(); - assert!(parse_cedar_version(serde_json::Value::String(invalid_version)).is_err()); + let err = parse_cedar_version(serde_json::Value::String(invalid_version)) + .expect_err("Expected error for incomplete version format (missing patch)"); + assert!( + err.to_string().contains("error parsing cedar version"), + "Error should mention version parsing, got: {}", + err + ); } /// Tests that an invalid version part (non-numeric) is rejected. #[test] fn test_invalid_version_part() { let invalid_version = "1.two.3".to_string(); - assert!(parse_cedar_version(serde_json::Value::String(invalid_version)).is_err()); + let err = parse_cedar_version(serde_json::Value::String(invalid_version)) + .expect_err("Expected error for non-numeric version part"); + assert!( + err.to_string().contains("error parsing cedar version"), + "Error should mention version parsing, got: {}", + err + ); } /// Tests that an invalid version format with 'v' prefix is rejected. #[test] fn test_invalid_version_format_with_v() { let invalid_version_with_v = "v1.2".to_string(); - assert!(parse_cedar_version(serde_json::Value::String(invalid_version_with_v)).is_err()); + let err = parse_cedar_version(serde_json::Value::String(invalid_version_with_v)) + .expect_err("Expected error for incomplete version format with v prefix"); + assert!( + err.to_string().contains("error parsing cedar version"), + "Error should mention version parsing, got: {}", + err + ); } #[test] @@ -242,6 +264,7 @@ fn test_parse_option_string() { #[test] fn test_missing_required_fields() { + // Test missing cedar_version let json = json!({ // Missing cedar_version "policy_stores": { @@ -254,29 +277,33 @@ fn test_missing_required_fields() { }); let result = serde_json::from_str::(&json.to_string()); - assert!(result.is_err()); - let err = result.unwrap_err(); + let err = result.expect_err("Expected error for missing cedar_version field"); assert!( err.to_string() - .contains("missing required field 'cedar_version' in policy store") + .contains("missing required field 'cedar_version' in policy store"), + "Error should mention missing cedar_version, got: {}", + err ); + // Test missing policy_stores let json = json!({ "cedar_version": "v4.0.0", // Missing policy_stores }); let result = serde_json::from_str::(&json.to_string()); - assert!(result.is_err()); - let err = result.unwrap_err(); + let err = result.expect_err("Expected error for missing policy_stores field"); assert!( err.to_string() - .contains("missing required field 'policy_stores' in policy store") + .contains("missing required field 'policy_stores' in policy store"), + "Error should mention missing policy_stores, got: {}", + err ); } #[test] fn test_invalid_policy_store_entry() { + // Test missing name in policy store entry let json = json!({ "cedar_version": "v4.0.0", "policy_stores": { @@ -289,13 +316,15 @@ fn test_invalid_policy_store_entry() { }); let result = serde_json::from_str::(&json.to_string()); - assert!(result.is_err()); - let err = result.unwrap_err(); + let err = result.expect_err("Expected error for missing name in policy store entry"); assert!( err.to_string() - .contains("missing required field 'name' in policy store entry") + .contains("missing required field 'name' in policy store entry"), + "Error should mention missing name field, got: {}", + err ); + // Test missing schema in policy store entry let json = json!({ "cedar_version": "v4.0.0", "policy_stores": { @@ -308,13 +337,15 @@ fn test_invalid_policy_store_entry() { }); let result = serde_json::from_str::(&json.to_string()); - assert!(result.is_err()); - let err = result.unwrap_err(); + let err = result.expect_err("Expected error for missing schema in policy store entry"); assert!( err.to_string() - .contains("missing required field 'schema' or 'cedar_schema' in policy store entry") + .contains("missing required field 'schema' or 'cedar_schema' in policy store entry"), + "Error should mention missing schema field, got: {}", + err ); + // Test missing policies in policy store entry let json = json!({ "cedar_version": "v4.0.0", "policy_stores": { @@ -327,12 +358,13 @@ fn test_invalid_policy_store_entry() { }); let result = serde_json::from_str::(&json.to_string()); - assert!(result.is_err()); - let err = result.unwrap_err(); + let err = result.expect_err("Expected error for missing policies in policy store entry"); assert!( err.to_string().contains( "missing required field 'policies' or 'cedar_policies' in policy store entry" - ) + ), + "Error should mention missing policies field, got: {}", + err ); } @@ -350,9 +382,12 @@ fn test_invalid_cedar_version() { }); let result = serde_json::from_str::(&json.to_string()); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.to_string().contains("invalid cedar_version format")); + let err = result.expect_err("Expected error for invalid cedar_version format"); + assert!( + err.to_string().contains("invalid cedar_version format"), + "Error should mention invalid cedar_version format, got: {}", + err + ); } #[test] @@ -369,9 +404,12 @@ fn test_invalid_schema_format() { }); let result = serde_json::from_str::(&json.to_string()); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.to_string().contains("error parsing schema")); + let err = result.expect_err("Expected error for invalid schema format"); + assert!( + err.to_string().contains("error parsing schema"), + "Error should mention schema parsing error, got: {}", + err + ); } #[test] @@ -394,10 +432,12 @@ fn test_invalid_policies_format() { }); let result = serde_json::from_str::(&json.to_string()); - assert!(result.is_err()); - let err = result.unwrap_err(); - println!("actual error: {}", err); - assert!(err.to_string().contains("unable to decode policy with id")); + let err = result.expect_err("Expected error for invalid policy content"); + assert!( + err.to_string().contains("unable to decode policy with id"), + "Error should mention unable to decode policy, got: {}", + err + ); } #[test] @@ -422,11 +462,11 @@ fn test_invalid_trusted_issuers_format() { }); let result = serde_json::from_str::(&json.to_string()); - assert!(result.is_err()); - let err = result.unwrap_err(); - println!("actual error: {:?}", err); + let err = result.expect_err("Expected error for invalid openid_configuration_endpoint URL"); assert!( err.to_string() - .contains("the `\"openid_configuration_endpoint\"` is not a valid url") + .contains("the `\"openid_configuration_endpoint\"` is not a valid url"), + "Error should mention invalid URL, got: {}", + err ); } diff --git a/jans-cedarling/cedarling/src/common/policy_store/test_utils.rs b/jans-cedarling/cedarling/src/common/policy_store/test_utils.rs new file mode 100644 index 00000000000..c69734066e3 --- /dev/null +++ b/jans-cedarling/cedarling/src/common/policy_store/test_utils.rs @@ -0,0 +1,594 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Test utilities for policy store testing. +//! +//! This module provides utilities for creating test policy stores programmatically, +//! including: +//! - `PolicyStoreTestBuilder` - Fluent builder for creating policy stores +//! - Test fixtures for valid and invalid policy stores +//! - Archive creation utilities for .cjar testing +//! - Performance testing utilities + +use super::errors::PolicyStoreError; +use chrono::Utc; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::io::{Cursor, Write}; +use zip::write::{ExtendedFileOptions, FileOptions}; +use zip::{CompressionMethod, ZipWriter}; + +/// Builder for creating test policy stores programmatically. +pub struct PolicyStoreTestBuilder { + /// Store ID (hex string) + pub id: String, + /// Store name + pub name: String, + /// Store version + pub version: String, + /// Cedar version + pub cedar_version: String, + /// Description + pub description: Option, + /// Schema content (Cedar schema format) + pub schema: String, + /// Policies: filename -> content + pub policies: HashMap, + /// Templates: filename -> content + pub templates: HashMap, + /// Entities: filename -> content + pub entities: HashMap, + /// Trusted issuers: filename -> content + pub trusted_issuers: HashMap, + /// Whether to generate manifest with checksums + pub generate_manifest: bool, + /// Additional files to include + pub extra_files: HashMap, +} + +impl Default for PolicyStoreTestBuilder { + fn default() -> Self { + Self::new("test123456789") + } +} + +impl PolicyStoreTestBuilder { + /// Create a new builder with the given store ID. + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + name: "Test Policy Store".to_string(), + version: "1.0.0".to_string(), + cedar_version: "4.4.0".to_string(), + description: None, + schema: Self::default_schema(), + policies: HashMap::new(), + templates: HashMap::new(), + entities: HashMap::new(), + trusted_issuers: HashMap::new(), + generate_manifest: false, + extra_files: HashMap::new(), + } + } + + /// Default minimal Cedar schema for testing. + pub fn default_schema() -> String { + r#"namespace TestApp { + entity User; + entity Resource; + entity Role; + + action "read" appliesTo { + principal: [User], + resource: [Resource] + }; + + action "write" appliesTo { + principal: [User], + resource: [Resource] + }; +} +"# + .to_string() + } + + /// Set the store name. + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + + /// Set the store version. + pub fn with_version(mut self, version: impl Into) -> Self { + self.version = version.into(); + self + } + + /// Set the description. + pub fn with_description(mut self, desc: impl Into) -> Self { + self.description = Some(desc.into()); + self + } + + /// Set a custom Cedar schema. + /// + /// If not called, a default minimal schema is used. + pub fn with_schema(mut self, schema: impl Into) -> Self { + self.schema = schema.into(); + self + } + + /// Add a policy file. + /// + /// # Arguments + /// * `name` - Filename without .cedar extension (e.g., "policy1" or "auth/admin") + /// * `content` - Cedar policy content with @id annotation + pub fn with_policy(mut self, name: impl Into, content: impl Into) -> Self { + self.policies.insert(name.into(), content.into()); + self + } + + /// Add an entity file. + /// + /// # Arguments + /// * `name` - Filename without .json extension (e.g., "users" or "roles/admin") + /// * `content` - JSON entity content + pub fn with_entity(mut self, name: impl Into, content: impl Into) -> Self { + self.entities.insert(name.into(), content.into()); + self + } + + /// Add a trusted issuer file. + pub fn with_trusted_issuer( + mut self, + name: impl Into, + content: impl Into, + ) -> Self { + self.trusted_issuers.insert(name.into(), content.into()); + self + } + + /// Enable manifest generation with checksums. + pub fn with_manifest(mut self) -> Self { + self.generate_manifest = true; + self + } + + /// Generate metadata.json content. + pub fn build_metadata_json(&self) -> String { + let mut metadata = serde_json::json!({ + "cedar_version": self.cedar_version, + "policy_store": { + "id": self.id, + "name": self.name, + "version": self.version + } + }); + + if let Some(desc) = &self.description { + metadata["policy_store"]["description"] = serde_json::Value::String(desc.clone()); + } + + serde_json::to_string_pretty(&metadata).unwrap() + } + + /// Generate manifest.json content with computed checksums. + fn build_manifest_json(&self, files: &HashMap>) -> String { + let mut manifest_files = HashMap::new(); + + for (path, content) in files { + if path != "manifest.json" { + let mut hasher = Sha256::new(); + hasher.update(content); + let hash = hex::encode(hasher.finalize()); + + manifest_files.insert( + path.clone(), + serde_json::json!({ + "size": content.len(), + "checksum": format!("sha256:{}", hash) + }), + ); + } + } + + let manifest = serde_json::json!({ + "policy_store_id": self.id, + "generated_date": Utc::now().to_rfc3339(), + "files": manifest_files + }); + + serde_json::to_string_pretty(&manifest).unwrap() + } + + /// Build all files as a HashMap (path -> content bytes). + fn build_files(&self) -> HashMap> { + let mut files: HashMap> = HashMap::new(); + + // Add metadata.json + files.insert( + "metadata.json".to_string(), + self.build_metadata_json().into_bytes(), + ); + + // Add schema.cedarschema + files.insert( + "schema.cedarschema".to_string(), + self.schema.as_bytes().to_vec(), + ); + + // Add policies + for (name, content) in &self.policies { + let path = format!("policies/{}.cedar", name); + files.insert(path, content.as_bytes().to_vec()); + } + + // Add templates + for (name, content) in &self.templates { + let path = format!("templates/{}.cedar", name); + files.insert(path, content.as_bytes().to_vec()); + } + + // Add entities + for (name, content) in &self.entities { + let path = format!("entities/{}.json", name); + files.insert(path, content.as_bytes().to_vec()); + } + + // Add trusted issuers + for (name, content) in &self.trusted_issuers { + let path = format!("trusted-issuers/{}.json", name); + files.insert(path, content.as_bytes().to_vec()); + } + + // Generate manifest if requested (must be last before extra_files) + if self.generate_manifest { + let manifest_content = self.build_manifest_json(&files); + files.insert("manifest.json".to_string(), manifest_content.into_bytes()); + } + + // Add extra files last, overwriting any generated files with the same path + for (path, content) in &self.extra_files { + files.insert(path.clone(), content.as_bytes().to_vec()); + } + + files + } + + /// Build policy store as .cjar archive bytes. + /// + /// Returns the archive as a byte vector suitable for `ArchiveVfs::from_buffer()`. + pub fn build_archive(&self) -> Result, PolicyStoreError> { + let files = self.build_files(); + let buffer = Vec::new(); + let cursor = Cursor::new(buffer); + let mut zip = ZipWriter::new(cursor); + + for (path, content) in files { + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + zip.start_file(&path, options) + .map_err(|e| PolicyStoreError::Io(std::io::Error::other(e)))?; + zip.write_all(&content).map_err(PolicyStoreError::Io)?; + } + + let cursor = zip + .finish() + .map_err(|e| PolicyStoreError::Io(std::io::Error::other(e)))?; + Ok(cursor.into_inner()) + } +} + +// ============================================================================ +// Test Fixtures +// ============================================================================ + +/// Pre-built test fixtures for common scenarios. +pub mod fixtures { + use super::*; + + /// Creates a minimal valid policy store. + pub fn minimal_valid() -> PolicyStoreTestBuilder { + PolicyStoreTestBuilder::new("abc123def456").with_policy( + "allow-all", + r#"@id("allow-all") +permit(principal, action, resource);"#, + ) + } + + /// Creates a policy store with multiple policies. + pub fn with_multiple_policies(count: usize) -> PolicyStoreTestBuilder { + let mut builder = PolicyStoreTestBuilder::new("multipolicy123"); + + for i in 0..count { + builder = builder.with_policy( + format!("policy{}", i), + format!( + r#"@id("policy{}") +permit( + principal == TestApp::User::"user{}", + action == TestApp::Action::"read", + resource == TestApp::Resource::"res{}" +);"#, + i, i, i + ), + ); + } + + builder + } + + /// Creates a policy store with multiple entities. + pub fn with_multiple_entities(count: usize) -> PolicyStoreTestBuilder { + let mut builder = PolicyStoreTestBuilder::new("multientity123").with_policy( + "allow-all", + r#"@id("allow-all") permit(principal, action, resource);"#, + ); + + // Create users + let mut users = Vec::new(); + for i in 0..count { + users.push(serde_json::json!({ + "uid": {"type": "TestApp::User", "id": format!("user{}", i)}, + "attrs": { + "name": format!("User {}", i), + "email": format!("user{}@example.com", i) + }, + "parents": [] + })); + } + + builder = builder.with_entity("users", serde_json::to_string_pretty(&users).unwrap()); + + builder + } + + // ======================================================================== + // Invalid Fixtures + // ======================================================================== + + /// Creates a policy store with invalid metadata JSON. + pub fn invalid_metadata_json() -> PolicyStoreTestBuilder { + let mut builder = minimal_valid(); + builder + .extra_files + .insert("metadata.json".to_string(), "{ invalid json }".to_string()); + builder + } + + /// Creates a policy store with invalid policy syntax. + pub fn invalid_policy_syntax() -> PolicyStoreTestBuilder { + PolicyStoreTestBuilder::new("invalidpolicy") + .with_policy("bad-policy", "permit ( principal action resource );") + } + + /// Creates a policy store with duplicate entity UIDs. + pub fn duplicate_entity_uids() -> PolicyStoreTestBuilder { + let users1 = serde_json::json!([{ + "uid": {"type": "TestApp::User", "id": "alice"}, + "attrs": {}, + "parents": [] + }]); + + let users2 = serde_json::json!([{ + "uid": {"type": "TestApp::User", "id": "alice"}, + "attrs": {}, + "parents": [] + }]); + + minimal_valid() + .with_entity("users1", users1.to_string()) + .with_entity("users2", users2.to_string()) + } + + /// Creates a policy store with invalid trusted issuer config. + pub fn invalid_trusted_issuer() -> PolicyStoreTestBuilder { + let issuer = serde_json::json!({ + "bad-issuer": { + "name": "Missing OIDC endpoint" + // Missing required oidc_endpoint field + } + }); + + minimal_valid().with_trusted_issuer("bad-issuer", issuer.to_string()) + } +} + +// ============================================================================ +// Archive Test Utilities +// ============================================================================ + +/// Creates a test archive with path traversal attempt. +pub fn create_path_traversal_archive() -> Vec { + let buffer = Vec::new(); + let cursor = Cursor::new(buffer); + let mut zip = ZipWriter::new(cursor); + + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + zip.start_file("../../../etc/passwd", options).unwrap(); + zip.write_all(b"malicious content").unwrap(); + + zip.finish().unwrap().into_inner() +} + +/// Creates a corrupted archive (invalid ZIP structure). +pub fn create_corrupted_archive() -> Vec { + // Start with valid ZIP header but corrupt it + let mut bytes = vec![0x50, 0x4B, 0x03, 0x04]; // ZIP local file header + bytes.extend_from_slice(&[0xFF; 100]); // Corrupted data + bytes +} + +/// Creates a deeply nested archive for path length testing. +pub fn create_deep_nested_archive(depth: usize) -> Vec { + let buffer = Vec::new(); + let cursor = Cursor::new(buffer); + let mut zip = ZipWriter::new(cursor); + + let path = (0..depth).map(|_| "dir").collect::>().join("/") + "/file.txt"; + + let options = FileOptions::::default() + .compression_method(CompressionMethod::Deflated); + zip.start_file(&path, options).unwrap(); + zip.write_all(b"deep content").unwrap(); + + zip.finish().unwrap().into_inner() +} + +// ============================================================================ +// Performance Testing Utilities +// ============================================================================ + +/// Creates a large policy store for load testing. +/// +/// # Arguments +/// * `policy_count` - Number of policies to generate +/// * `entity_count` - Number of entities to generate +/// * `issuer_count` - Number of trusted issuers to generate +pub fn create_large_policy_store( + policy_count: usize, + entity_count: usize, + issuer_count: usize, +) -> PolicyStoreTestBuilder { + let mut builder = PolicyStoreTestBuilder::new("loadtest123456"); + + // Generate policies + for i in 0..policy_count { + builder = builder.with_policy( + format!("policy{:06}", i), + format!( + r#"@id("policy{:06}") +permit( + principal == TestApp::User::"user{:06}", + action == TestApp::Action::"read", + resource == TestApp::Resource::"resource{:06}" +) when {{ + principal has email && principal.email like "*@example.com" +}};"#, + i, + i % entity_count, + i % 100 + ), + ); + } + + // Generate entities in batches + let batch_size = 1000; + let entity_batches = entity_count.div_ceil(batch_size); + + for batch in 0..entity_batches { + let start = batch * batch_size; + let end = ((batch + 1) * batch_size).min(entity_count); + + let entities: Vec<_> = (start..end) + .map(|i| { + serde_json::json!({ + "uid": {"type": "TestApp::User", "id": format!("user{:06}", i)}, + "attrs": { + "name": format!("User {}", i), + "email": format!("user{}@example.com", i), + "department": format!("dept{}", i % 10) + }, + "parents": [] + }) + }) + .collect(); + + builder = builder.with_entity( + format!("users_batch{:04}", batch), + serde_json::to_string(&entities).unwrap(), + ); + } + + // Generate trusted issuers + for i in 0..issuer_count { + let issuer = serde_json::json!({ + format!("issuer{}", i): { + "name": format!("Issuer {}", i), + "openid_configuration_endpoint": format!("https://issuer{}.example.com/.well-known/openid-configuration", i), + "token_metadata": { + "access_token": { + "entity_type_name": "issuer", + "user_id": "sub", + "required_claims": ["sub"] + } + } + } + }); + builder = builder.with_trusted_issuer(format!("issuer{}", i), issuer.to_string()); + } + + builder +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_builder_creates_valid_metadata() { + let builder = PolicyStoreTestBuilder::new("test123abc456") + .with_name("My Test Store") + .with_version("2.0.0") + .with_description("A test store"); + + let metadata_json = builder.build_metadata_json(); + let metadata: serde_json::Value = serde_json::from_str(&metadata_json).unwrap(); + + assert_eq!(metadata["cedar_version"], "4.4.0"); + assert_eq!(metadata["policy_store"]["id"], "test123abc456"); + assert_eq!(metadata["policy_store"]["name"], "My Test Store"); + assert_eq!(metadata["policy_store"]["version"], "2.0.0"); + assert_eq!(metadata["policy_store"]["description"], "A test store"); + } + + #[test] + fn test_builder_creates_archive() { + let builder = fixtures::minimal_valid(); + let archive = builder.build_archive().unwrap(); + + // Verify it's a valid ZIP + assert!(!archive.is_empty()); + assert_eq!(&archive[0..2], &[0x50, 0x4B]); // ZIP magic number + } + + #[test] + fn test_fixture_with_multiple_policies() { + let builder = fixtures::with_multiple_policies(10); + assert_eq!(builder.policies.len(), 10); + } + + #[test] + fn test_fixture_with_multiple_entities() { + let builder = fixtures::with_multiple_entities(100); + assert_eq!(builder.entities.len(), 1); // All in one file + } + + #[test] + fn test_large_policy_store_creation() { + let builder = create_large_policy_store(100, 1000, 5); + assert_eq!(builder.policies.len(), 100); + assert_eq!(builder.trusted_issuers.len(), 5); + } + + #[test] + fn test_path_traversal_archive() { + let archive = create_path_traversal_archive(); + assert!(!archive.is_empty()); + } + + #[test] + fn test_corrupted_archive() { + let archive = create_corrupted_archive(); + assert!(!archive.is_empty()); + } + + #[test] + fn test_deep_nested_archive() { + let archive = create_deep_nested_archive(50); + assert!(!archive.is_empty()); + } +} diff --git a/jans-cedarling/cedarling/src/common/policy_store/validator.rs b/jans-cedarling/cedarling/src/common/policy_store/validator.rs new file mode 100644 index 00000000000..18e0fbdee84 --- /dev/null +++ b/jans-cedarling/cedarling/src/common/policy_store/validator.rs @@ -0,0 +1,671 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Policy store metadata validation and parsing. + +use super::errors::ValidationError; +use super::metadata::{PolicyStoreInfo, PolicyStoreMetadata}; +use semver::Version; + +/// Maximum allowed length for policy store description. +pub const DESCRIPTION_MAX_LENGTH: usize = 1000; + +/// Validator for policy store metadata. +pub struct MetadataValidator; + +impl MetadataValidator { + /// Validate a PolicyStoreMetadata structure. + /// + /// Checks: + /// - Cedar version format is valid + /// - Policy store name is not empty + /// - Policy store version is valid semantic version (if provided) + /// - Policy store ID format is valid (if provided) + pub fn validate(metadata: &PolicyStoreMetadata) -> Result<(), ValidationError> { + // Validate cedar_version + Self::validate_cedar_version(&metadata.cedar_version)?; + + // Validate policy_store fields + Self::validate_policy_store_info(&metadata.policy_store)?; + + Ok(()) + } + + /// Validate Cedar version string. + /// + /// Expected format: "X.Y.Z" where X, Y, Z are integers + /// Examples: "4.4.0", "3.0.1", "4.2.5" + fn validate_cedar_version(version: &str) -> Result<(), ValidationError> { + if version.is_empty() { + return Err(ValidationError::EmptyCedarVersion); + } + + // Parse as semantic version + Version::parse(version).map_err(|e| ValidationError::InvalidCedarVersion { + version: version.to_string(), + details: e.to_string(), + })?; + + Ok(()) + } + + /// Validate policy store info fields. + fn validate_policy_store_info(info: &PolicyStoreInfo) -> Result<(), ValidationError> { + // Validate name (required) + if info.name.is_empty() { + return Err(ValidationError::EmptyPolicyStoreName); + } + + // Validate name length (reasonable limit) + if info.name.len() > 255 { + return Err(ValidationError::PolicyStoreNameTooLong { + length: info.name.len(), + }); + } + + // Validate ID format if provided (should be hex string or empty) + if !info.id.is_empty() { + Self::validate_policy_store_id(&info.id)?; + } + + // Validate version format if provided (should be semantic version) + if !info.version.is_empty() { + Self::validate_policy_store_version(&info.version)?; + } + + // Validate description length if provided + if let Some(desc) = &info.description + && desc.len() > DESCRIPTION_MAX_LENGTH + { + return Err(ValidationError::DescriptionTooLong { + length: desc.len(), + max_length: DESCRIPTION_MAX_LENGTH, + }); + } + + // Validate timestamps ordering if both are provided + if let (Some(created), Some(updated)) = (info.created_date, info.updated_date) + && updated < created + { + return Err(ValidationError::InvalidTimestampOrdering); + } + + Ok(()) + } + + /// Validate policy store ID format. + /// + /// Expected: Hexadecimal string (lowercase or uppercase) + /// Examples: "abc123", "ABC123", "0123456789abcdef" + fn validate_policy_store_id(id: &str) -> Result<(), ValidationError> { + // Check if all characters are valid hex and length is 8-64 chars + if !id.chars().all(|c| c.is_ascii_hexdigit()) || id.len() < 8 || id.len() > 64 { + return Err(ValidationError::InvalidPolicyStoreId { id: id.to_string() }); + } + + Ok(()) + } + + /// Validate policy store version. + /// + /// Expected: Semantic version (X.Y.Z or X.Y.Z-prerelease+build) + /// Examples: "1.0.0", "2.1.3", "1.0.0-alpha", "1.0.0-beta.1+build.123" + fn validate_policy_store_version(version: &str) -> Result<(), ValidationError> { + Version::parse(version).map_err(|e| ValidationError::InvalidPolicyStoreVersion { + version: version.to_string(), + details: e.to_string(), + })?; + + Ok(()) + } + + /// Parse and validate metadata from JSON string. + pub fn parse_and_validate(json: &str) -> Result { + // Parse JSON + let metadata: PolicyStoreMetadata = + serde_json::from_str(json).map_err(|e| ValidationError::MetadataJsonParseFailed { + file: "metadata.json".to_string(), + source: e, + })?; + + // Validate + Self::validate(&metadata)?; + + Ok(metadata) + } +} + +/// Accessor methods for policy store metadata. +impl PolicyStoreMetadata { + /// Get the Cedar version. + pub fn cedar_version(&self) -> &str { + &self.cedar_version + } + + /// Get the policy store ID. + pub fn id(&self) -> &str { + &self.policy_store.id + } + + /// Get the policy store name. + pub fn name(&self) -> &str { + &self.policy_store.name + } + + /// Get the policy store description. + pub fn description(&self) -> Option<&str> { + self.policy_store.description.as_deref() + } + + /// Get the policy store version. + pub fn version(&self) -> &str { + &self.policy_store.version + } + + /// Get the policy store version as a parsed semantic version. + pub fn version_parsed(&self) -> Option { + Version::parse(&self.policy_store.version).ok() + } + + /// Get the policy store created date. + pub fn created_date(&self) -> Option> { + self.policy_store.created_date + } + + /// Get the policy store updated date. + pub fn updated_date(&self) -> Option> { + self.policy_store.updated_date + } + + /// Check if this policy store is compatible with a given Cedar version. + pub fn is_compatible_with_cedar( + &self, + required_version: &Version, + ) -> Result { + let store_version = Version::parse(&self.cedar_version).map_err(|e| { + ValidationError::MetadataInvalidCedarVersion { + file: "metadata.json".to_string(), + source: e, + } + })?; + + // Check if the store version is compatible with the required version + if store_version.major == required_version.major { + return Ok(store_version >= *required_version); + } + + Ok(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{DateTime, Utc}; + + #[test] + fn test_validate_valid_metadata() { + let metadata = PolicyStoreMetadata { + cedar_version: "4.4.0".to_string(), + policy_store: PolicyStoreInfo { + id: "abc123def456".to_string(), + name: "Test Policy Store".to_string(), + description: Some("A test policy store".to_string()), + version: "1.0.0".to_string(), + created_date: None, + updated_date: None, + }, + }; + + let result = MetadataValidator::validate(&metadata); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_minimal_metadata() { + let metadata = PolicyStoreMetadata { + cedar_version: "4.0.0".to_string(), + policy_store: PolicyStoreInfo { + id: String::new(), + name: "Minimal Store".to_string(), + description: None, + version: String::new(), + created_date: None, + updated_date: None, + }, + }; + + let result = MetadataValidator::validate(&metadata); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_empty_cedar_version() { + let metadata = PolicyStoreMetadata { + cedar_version: String::new(), + policy_store: PolicyStoreInfo { + id: String::new(), + name: "Test".to_string(), + description: None, + version: String::new(), + created_date: None, + updated_date: None, + }, + }; + + let result = MetadataValidator::validate(&metadata); + let err = result.expect_err("Expected EmptyCedarVersion error"); + assert!( + matches!(err, ValidationError::EmptyCedarVersion), + "Expected EmptyCedarVersion, got: {:?}", + err + ); + } + + #[test] + fn test_validate_invalid_cedar_version() { + let metadata = PolicyStoreMetadata { + cedar_version: "invalid.version".to_string(), + policy_store: PolicyStoreInfo { + id: String::new(), + name: "Test".to_string(), + description: None, + version: String::new(), + created_date: None, + updated_date: None, + }, + }; + + let result = MetadataValidator::validate(&metadata); + let err = result.expect_err("Expected InvalidCedarVersion error"); + assert!( + matches!(err, ValidationError::InvalidCedarVersion { .. }), + "Expected InvalidCedarVersion, got: {:?}", + err + ); + } + + #[test] + fn test_validate_empty_name() { + let metadata = PolicyStoreMetadata { + cedar_version: "4.4.0".to_string(), + policy_store: PolicyStoreInfo { + id: String::new(), + name: String::new(), + description: None, + version: String::new(), + created_date: None, + updated_date: None, + }, + }; + + let result = MetadataValidator::validate(&metadata); + let err = result.expect_err("Expected EmptyPolicyStoreName error"); + assert!( + matches!(err, ValidationError::EmptyPolicyStoreName), + "Expected EmptyPolicyStoreName, got: {:?}", + err + ); + } + + #[test] + fn test_validate_name_too_long() { + let metadata = PolicyStoreMetadata { + cedar_version: "4.4.0".to_string(), + policy_store: PolicyStoreInfo { + id: String::new(), + name: "a".repeat(256), + description: None, + version: String::new(), + created_date: None, + updated_date: None, + }, + }; + + let result = MetadataValidator::validate(&metadata); + let err = result.expect_err("Expected PolicyStoreNameTooLong error"); + assert!( + matches!(err, ValidationError::PolicyStoreNameTooLong { length: 256 }), + "Expected PolicyStoreNameTooLong with length 256, got: {:?}", + err + ); + } + + #[test] + fn test_validate_invalid_id_format() { + let metadata = PolicyStoreMetadata { + cedar_version: "4.4.0".to_string(), + policy_store: PolicyStoreInfo { + id: "invalid-id-with-dashes".to_string(), + name: "Test".to_string(), + description: None, + version: String::new(), + created_date: None, + updated_date: None, + }, + }; + + let result = MetadataValidator::validate(&metadata); + let err = result.expect_err("Expected InvalidPolicyStoreId error"); + assert!( + matches!(err, ValidationError::InvalidPolicyStoreId { .. }), + "Expected InvalidPolicyStoreId, got: {:?}", + err + ); + } + + #[test] + fn test_validate_id_too_short() { + let metadata = PolicyStoreMetadata { + cedar_version: "4.4.0".to_string(), + policy_store: PolicyStoreInfo { + id: "abc123".to_string(), // Only 6 chars, need 8+ + name: "Test".to_string(), + description: None, + version: String::new(), + created_date: None, + updated_date: None, + }, + }; + + let result = MetadataValidator::validate(&metadata); + let err = result.expect_err("Expected InvalidPolicyStoreId error for short ID"); + assert!( + matches!(err, ValidationError::InvalidPolicyStoreId { .. }), + "Expected InvalidPolicyStoreId, got: {:?}", + err + ); + } + + #[test] + fn test_validate_valid_hex_id() { + let metadata = PolicyStoreMetadata { + cedar_version: "4.4.0".to_string(), + policy_store: PolicyStoreInfo { + id: "0123456789abcdef".to_string(), + name: "Test".to_string(), + description: None, + version: String::new(), + created_date: None, + updated_date: None, + }, + }; + + let result = MetadataValidator::validate(&metadata); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_invalid_version() { + let metadata = PolicyStoreMetadata { + cedar_version: "4.4.0".to_string(), + policy_store: PolicyStoreInfo { + id: String::new(), + name: "Test".to_string(), + description: None, + version: "not.a.version".to_string(), + created_date: None, + updated_date: None, + }, + }; + + let result = MetadataValidator::validate(&metadata); + let err = result.expect_err("Expected InvalidPolicyStoreVersion error"); + assert!( + matches!(err, ValidationError::InvalidPolicyStoreVersion { .. }), + "Expected InvalidPolicyStoreVersion, got: {:?}", + err + ); + } + + #[test] + fn test_validate_valid_semver() { + let metadata = PolicyStoreMetadata { + cedar_version: "4.4.0".to_string(), + policy_store: PolicyStoreInfo { + id: String::new(), + name: "Test".to_string(), + description: None, + version: "1.2.3-alpha.1+build.456".to_string(), + created_date: None, + updated_date: None, + }, + }; + + let result = MetadataValidator::validate(&metadata); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_description_too_long() { + let over_limit = DESCRIPTION_MAX_LENGTH + 1; + let metadata = PolicyStoreMetadata { + cedar_version: "4.4.0".to_string(), + policy_store: PolicyStoreInfo { + id: String::new(), + name: "Test".to_string(), + description: Some("a".repeat(over_limit)), + version: String::new(), + created_date: None, + updated_date: None, + }, + }; + + let result = MetadataValidator::validate(&metadata); + let err = result.expect_err("Expected DescriptionTooLong error"); + assert!( + matches!( + err, + ValidationError::DescriptionTooLong { length, max_length } + if length == over_limit && max_length == DESCRIPTION_MAX_LENGTH + ), + "Expected DescriptionTooLong with correct limits, got: {:?}", + err + ); + } + + #[test] + fn test_validate_timestamp_ordering() { + let created = DateTime::parse_from_rfc3339("2024-01-02T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + let updated = DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + + let metadata = PolicyStoreMetadata { + cedar_version: "4.4.0".to_string(), + policy_store: PolicyStoreInfo { + id: String::new(), + name: "Test".to_string(), + description: None, + version: String::new(), + created_date: Some(created), + updated_date: Some(updated), + }, + }; + + let result = MetadataValidator::validate(&metadata); + let err = result.expect_err("Expected InvalidTimestampOrdering error"); + assert!( + matches!(err, ValidationError::InvalidTimestampOrdering), + "Expected InvalidTimestampOrdering, got: {:?}", + err + ); + } + + #[test] + fn test_parse_and_validate_valid_json() { + let json = r#"{ + "cedar_version": "4.4.0", + "policy_store": { + "id": "abc123def456", + "name": "Test Store", + "version": "1.0.0" + } + }"#; + + let result = MetadataValidator::parse_and_validate(json); + assert!(result.is_ok()); + let metadata = result.unwrap(); + assert_eq!(metadata.cedar_version, "4.4.0"); + assert_eq!(metadata.policy_store.name, "Test Store"); + } + + #[test] + fn test_parse_and_validate_invalid_json() { + let json = r#"{ invalid json }"#; + + let result = MetadataValidator::parse_and_validate(json); + let err = result.expect_err("Should fail on invalid JSON"); + assert!(matches!( + err, + ValidationError::MetadataJsonParseFailed { .. } + )); + } + + #[test] + fn test_parse_and_validate_missing_required_field() { + // Missing the 'name' field entirely - should fail during JSON deserialization + let json = r#"{ + "cedar_version": "4.4.0", + "policy_store": { + "id": "abc123def456" + } + }"#; + + let result = MetadataValidator::parse_and_validate(json); + let err = result.expect_err("Should fail on missing required field"); + assert!(matches!( + err, + ValidationError::MetadataJsonParseFailed { .. } + )); + } + + #[test] + fn test_parse_and_validate_empty_name_validation() { + // Empty name field - should pass JSON parsing but fail validation + let json = r#"{ + "cedar_version": "4.4.0", + "policy_store": { + "id": "abc123def456", + "name": "" + } + }"#; + + let result = MetadataValidator::parse_and_validate(json); + let err = result.expect_err("Should fail on empty name validation"); + assert!(matches!(err, ValidationError::EmptyPolicyStoreName)); + } + + #[test] + fn test_accessor_methods() { + let created = DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + + let metadata = PolicyStoreMetadata { + cedar_version: "4.4.0".to_string(), + policy_store: PolicyStoreInfo { + id: "abc123def456".to_string(), + name: "Test Store".to_string(), + description: Some("Test description".to_string()), + version: "1.2.3".to_string(), + created_date: Some(created), + updated_date: None, + }, + }; + + assert_eq!(metadata.cedar_version(), "4.4.0"); + assert_eq!(metadata.id(), "abc123def456"); + assert_eq!(metadata.name(), "Test Store"); + assert_eq!(metadata.description(), Some("Test description")); + assert_eq!(metadata.version(), "1.2.3"); + assert!(metadata.version_parsed().is_some()); + assert_eq!(metadata.version_parsed().unwrap().to_string(), "1.2.3"); + assert!(metadata.created_date().is_some()); + assert!(metadata.updated_date().is_none()); + } + + #[test] + fn test_cedar_version_compatibility_same_version() { + let metadata = PolicyStoreMetadata { + cedar_version: "4.4.0".to_string(), + policy_store: PolicyStoreInfo { + id: String::new(), + name: "Test".to_string(), + description: None, + version: String::new(), + created_date: None, + updated_date: None, + }, + }; + + let is_compatible = metadata + .is_compatible_with_cedar(&Version::new(4, 4, 0)) + .expect("Should successfully check compatibility"); + assert!(is_compatible); + } + + #[test] + fn test_cedar_version_compatibility_newer_minor() { + let metadata = PolicyStoreMetadata { + cedar_version: "4.5.0".to_string(), + policy_store: PolicyStoreInfo { + id: String::new(), + name: "Test".to_string(), + description: None, + version: String::new(), + created_date: None, + updated_date: None, + }, + }; + + let is_compatible = metadata + .is_compatible_with_cedar(&Version::new(4, 4, 0)) + .expect("Should successfully check compatibility"); + assert!(is_compatible); + } + + #[test] + fn test_cedar_version_compatibility_different_major() { + let metadata = PolicyStoreMetadata { + cedar_version: "4.4.0".to_string(), + policy_store: PolicyStoreInfo { + id: String::new(), + name: "Test".to_string(), + description: None, + version: String::new(), + created_date: None, + updated_date: None, + }, + }; + + let is_compatible = metadata + .is_compatible_with_cedar(&Version::new(3, 0, 0)) + .expect("Should successfully check compatibility"); + assert!(!is_compatible); + } + + #[test] + fn test_cedar_version_compatibility_older_minor() { + let metadata = PolicyStoreMetadata { + cedar_version: "4.3.0".to_string(), + policy_store: PolicyStoreInfo { + id: String::new(), + name: "Test".to_string(), + description: None, + version: String::new(), + created_date: None, + updated_date: None, + }, + }; + + let is_compatible = metadata + .is_compatible_with_cedar(&Version::new(4, 4, 0)) + .expect("Should successfully check compatibility"); + assert!(!is_compatible); + } +} diff --git a/jans-cedarling/cedarling/src/common/policy_store/vfs_adapter.rs b/jans-cedarling/cedarling/src/common/policy_store/vfs_adapter.rs new file mode 100644 index 00000000000..e4fc3793325 --- /dev/null +++ b/jans-cedarling/cedarling/src/common/policy_store/vfs_adapter.rs @@ -0,0 +1,390 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Virtual File System (VFS) adapter for policy store loading. +//! +//! This module provides an abstraction layer over filesystem operations to enable: +//! - Native filesystem access on non-WASM platforms +//! - In-memory filesystem for testing and WASM environments +//! - Future support for archive (.cjar) loading +//! +//! The VFS abstraction allows the policy store loader to work uniformly across +//! different storage backends without changing the loading logic. + +use std::io::{self, Read}; + +#[cfg(test)] +use std::path::Path; + +/// Represents a directory entry from VFS. +#[derive(Debug, Clone)] +pub struct DirEntry { + /// The file name + pub name: String, + /// The full path + pub path: String, + /// Whether this is a directory + pub is_dir: bool, +} + +/// Trait for virtual filesystem operations. +/// +/// This trait abstracts filesystem operations to enable testing and cross-platform support. +pub trait VfsFileSystem: Send + Sync + 'static { + /// Open a file and return a reader. + /// + /// This is the primary method for reading files, allowing callers to: + /// - Read incrementally (memory efficient for large files) + /// - Use standard I/O traits like `BufReader` + /// - Control buffer sizes + fn open_file(&self, path: &str) -> io::Result>; + + /// Read the entire contents of a file into memory. + /// + /// This is a convenience method that reads the entire file. + /// For large files, consider using `open_file` instead. + fn read_file(&self, path: &str) -> io::Result> { + let mut reader = self.open_file(path)?; + let mut buffer = Vec::new(); + reader.read_to_end(&mut buffer)?; + Ok(buffer) + } + + /// Read directory entries. + fn read_dir(&self, path: &str) -> io::Result>; + + /// Check if a path exists. + fn exists(&self, path: &str) -> bool; + + /// Check if a path is a directory. + fn is_dir(&self, path: &str) -> bool; + + /// Check if a path is a file. + /// This method is only used in non-WASM builds for manifest validation. + /// Implementations should provide this method even if not used in WASM builds. + fn is_file(&self, path: &str) -> bool; +} + +/// Physical filesystem implementation for native platforms. +/// +/// Uses the actual filesystem via the `vfs::PhysicalFS` backend. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Debug)] +pub struct PhysicalVfs { + root: vfs::VfsPath, +} + +#[cfg(not(target_arch = "wasm32"))] +impl PhysicalVfs { + /// Create a new physical VFS rooted at the system root. + pub fn new() -> Self { + let root = vfs::PhysicalFS::new("/").into(); + Self { root } + } + + /// Helper to get a VfsPath from a string path. + fn get_path(&self, path: &str) -> io::Result { + self.root + .join(path) + .map_err(|e| io::Error::other(format!("Invalid path '{}': {}", path, e))) + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl Default for PhysicalVfs { + fn default() -> Self { + Self::new() + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl VfsFileSystem for PhysicalVfs { + fn open_file(&self, path: &str) -> io::Result> { + let vfs_path = self.get_path(path)?; + let file = vfs_path + .open_file() + .map_err(|e| io::Error::new(io::ErrorKind::NotFound, e))?; + Ok(Box::new(file)) + } + + fn read_dir(&self, path: &str) -> io::Result> { + let vfs_path = self.get_path(path)?; + let entries = vfs_path + .read_dir() + .map_err(|e| io::Error::new(io::ErrorKind::NotFound, e))?; + + let mut result = Vec::new(); + for entry in entries { + let metadata = entry.metadata().map_err(io::Error::other)?; + let filename = entry.filename(); + let full_path = entry.as_str().to_string(); + + result.push(DirEntry { + name: filename, + path: full_path, + is_dir: metadata.file_type == vfs::VfsFileType::Directory, + }); + } + + Ok(result) + } + + fn exists(&self, path: &str) -> bool { + self.get_path(path) + .map(|p| p.exists().unwrap_or(false)) + .unwrap_or(false) + } + + fn is_dir(&self, path: &str) -> bool { + self.get_path(path) + .ok() + .and_then(|p| p.metadata().ok()) + .map(|m| m.file_type == vfs::VfsFileType::Directory) + .unwrap_or(false) + } + + fn is_file(&self, path: &str) -> bool { + self.get_path(path) + .ok() + .and_then(|p| p.metadata().ok()) + .map(|m| m.file_type == vfs::VfsFileType::File) + .unwrap_or(false) + } +} + +/// In-memory filesystem implementation for testing. +/// +/// Uses `vfs::MemoryFS` to store files in memory. This is useful for: +/// - Unit testing without touching the real filesystem +/// - Building policy stores programmatically in memory for tests +#[cfg(test)] +#[derive(Debug)] +pub struct MemoryVfs { + root: vfs::VfsPath, +} + +#[cfg(test)] +impl MemoryVfs { + /// Create a new empty in-memory VFS. + pub fn new() -> Self { + let root = vfs::MemoryFS::new().into(); + Self { root } + } + + /// Helper to get a VfsPath from a string path. + fn get_path(&self, path: &str) -> io::Result { + self.root + .join(path) + .map_err(|e| io::Error::other(format!("Invalid path '{}': {}", path, e))) + } + + /// Create a file with the given content. + pub fn create_file(&self, path: &str, content: &[u8]) -> io::Result<()> { + let vfs_path = self.get_path(path)?; + + // Create parent directories if needed + if let Some(parent) = Path::new(path).parent() + && !parent.as_os_str().is_empty() + { + let parent_str = parent.to_str().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "Invalid parent path") + })?; + self.create_dir_all(parent_str)?; + } + + let mut file = vfs_path.create_file().map_err(io::Error::other)?; + std::io::Write::write_all(&mut file, content)?; + Ok(()) + } + + /// Create a directory and all of its parents. + pub fn create_dir_all(&self, path: &str) -> io::Result<()> { + let vfs_path = self.get_path(path)?; + vfs_path.create_dir_all().map_err(io::Error::other) + } +} + +#[cfg(test)] +impl Default for MemoryVfs { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +impl VfsFileSystem for MemoryVfs { + fn open_file(&self, path: &str) -> io::Result> { + let vfs_path = self.get_path(path)?; + let file = vfs_path + .open_file() + .map_err(|e| io::Error::new(io::ErrorKind::NotFound, e))?; + Ok(Box::new(file)) + } + + fn read_dir(&self, path: &str) -> io::Result> { + let vfs_path = self.get_path(path)?; + let entries = vfs_path + .read_dir() + .map_err(|e| io::Error::new(io::ErrorKind::NotFound, e))?; + + let mut result = Vec::new(); + for entry in entries { + let metadata = entry.metadata().map_err(io::Error::other)?; + let filename = entry.filename(); + let full_path = entry.as_str().to_string(); + + result.push(DirEntry { + name: filename, + path: full_path, + is_dir: metadata.file_type == vfs::VfsFileType::Directory, + }); + } + + Ok(result) + } + + fn exists(&self, path: &str) -> bool { + self.get_path(path) + .map(|p| p.exists().unwrap_or(false)) + .unwrap_or(false) + } + + fn is_dir(&self, path: &str) -> bool { + self.get_path(path) + .ok() + .and_then(|p| p.metadata().ok()) + .map(|m| m.file_type == vfs::VfsFileType::Directory) + .unwrap_or(false) + } + + fn is_file(&self, path: &str) -> bool { + self.get_path(path) + .ok() + .and_then(|p| p.metadata().ok()) + .map(|m| m.file_type == vfs::VfsFileType::File) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_memory_vfs_create_and_read_file() { + let vfs = MemoryVfs::new(); + let content = b"test content"; + + vfs.create_file("/test.txt", content).unwrap(); + + assert!(vfs.exists("/test.txt")); + assert!(vfs.is_file("/test.txt")); + assert!(!vfs.is_dir("/test.txt")); + + let read_content = vfs.read_file("/test.txt").unwrap(); + assert_eq!(read_content, content); + } + + #[test] + fn test_memory_vfs_create_dir() { + let vfs = MemoryVfs::new(); + + vfs.create_dir_all("/test/nested/dir").unwrap(); + + assert!(vfs.exists("/test")); + assert!(vfs.is_dir("/test")); + assert!(!vfs.is_file("/test")); + + assert!(vfs.exists("/test/nested/dir")); + assert!(vfs.is_dir("/test/nested/dir")); + } + + #[test] + fn test_memory_vfs_read_dir() { + let vfs = MemoryVfs::new(); + + vfs.create_file("/test/file1.txt", b"content1").unwrap(); + vfs.create_file("/test/file2.txt", b"content2").unwrap(); + vfs.create_dir_all("/test/subdir").unwrap(); + + let entries = vfs.read_dir("/test").unwrap(); + assert_eq!(entries.len(), 3); + + let names: Vec = entries.iter().map(|e| e.name.clone()).collect(); + assert!(names.contains(&"file1.txt".to_string())); + assert!(names.contains(&"file2.txt".to_string())); + assert!(names.contains(&"subdir".to_string())); + } + + #[test] + fn test_memory_vfs_nonexistent_file() { + let vfs = MemoryVfs::new(); + + assert!(!vfs.exists("/nonexistent.txt")); + let result = vfs.read_file("/nonexistent.txt"); + result.expect_err("Expected error when reading nonexistent file"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn test_physical_vfs_exists() { + use std::fs; + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.txt"); + fs::write(&test_file, b"test").unwrap(); + + let vfs = PhysicalVfs::new(); + let path_str = test_file.to_str().unwrap(); + + assert!(vfs.exists(path_str)); + assert!(vfs.is_file(path_str)); + assert!(!vfs.is_dir(path_str)); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn test_physical_vfs_read_file() { + use std::fs; + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.txt"); + let content = b"test content"; + fs::write(&test_file, content).unwrap(); + + let vfs = PhysicalVfs::new(); + let path_str = test_file.to_str().unwrap(); + + let read_content = vfs.read_file(path_str).unwrap(); + assert_eq!(read_content, content); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn test_physical_vfs_read_dir() { + use std::fs; + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let dir_path = temp_dir.path(); + + fs::write(dir_path.join("file1.txt"), b"content1").unwrap(); + fs::write(dir_path.join("file2.txt"), b"content2").unwrap(); + fs::create_dir(dir_path.join("subdir")).unwrap(); + + let vfs = PhysicalVfs::new(); + let path_str = dir_path.to_str().unwrap(); + + let entries = vfs.read_dir(path_str).unwrap(); + assert_eq!(entries.len(), 3); + + let names: Vec = entries.iter().map(|e| e.name.clone()).collect(); + assert!(names.contains(&"file1.txt".to_string())); + assert!(names.contains(&"file2.txt".to_string())); + assert!(names.contains(&"subdir".to_string())); + } +} diff --git a/jans-cedarling/cedarling/src/http/mod.rs b/jans-cedarling/cedarling/src/http/mod.rs index 8b1a798e63e..3add917aa1c 100644 --- a/jans-cedarling/cedarling/src/http/mod.rs +++ b/jans-cedarling/cedarling/src/http/mod.rs @@ -7,74 +7,62 @@ mod spawn_task; pub use spawn_task::*; +use http_utils::{Backoff, HttpRequestError, Sender}; use reqwest::Client; use serde::Deserialize; use std::time::Duration; -/// A wrapper around `reqwest::blocking::Client` providing HTTP request functionality -/// with retry logic. +/// A wrapper around `reqwest::Client` providing HTTP request functionality +/// with retry logic using exponential backoff. /// /// The `HttpClient` struct allows for sending GET requests with a retry mechanism /// that attempts to fetch the requested resource up to a maximum number of times -/// if an error occurs. +/// if an error occurs, using the `Sender` and `Backoff` utilities from `http_utils`. #[derive(Debug)] pub struct HttpClient { - client: reqwest::Client, + client: Client, + base_delay: Duration, max_retries: u32, - retry_delay: Duration, } impl HttpClient { pub fn new(max_retries: u32, retry_delay: Duration) -> Result { let client = Client::builder() .build() - .map_err(HttpClientError::Initialization)?; + .map_err(HttpRequestError::InitializeHttpClient)?; Ok(Self { client, + base_delay: retry_delay, max_retries, - retry_delay, }) } -} -impl HttpClient { + /// Creates a new Sender with the configured backoff strategy. + fn create_sender(&self) -> Sender { + Sender::new(Backoff::new_exponential( + self.base_delay, + Some(self.max_retries), + )) + } + /// Sends a GET request to the specified URI with retry logic. - /// - /// This method will attempt to fetch the resource up to 3 times, with an increasing delay - /// between each attempt. pub async fn get(&self, uri: &str) -> Result { - // Fetch the JWKS from the jwks_uri - let mut attempts = 0; - let response = loop { - match self.client.get(uri).send().await { - // Exit loop on success - Ok(response) => break response, - - Err(e) if attempts < self.max_retries => { - attempts += 1; - // TODO: pass this message to the logger - eprintln!( - "Request failed (attempt {} of {}): {}. Retrying...", - attempts, self.max_retries, e - ); - tokio::time::sleep(self.retry_delay * attempts).await; - }, - // Exit if max retries exceeded - Err(e) => return Err(HttpClientError::MaxHttpRetriesReached(e)), - } - }; - - let response = response - .error_for_status() - .map_err(HttpClientError::HttpStatus)?; - - Ok(Response { - text: response - .text() - .await - .map_err(HttpClientError::DecodeResponseUtf8)?, - }) + let mut sender = self.create_sender(); + let client = &self.client; + let text = sender.send_text(|| client.get(uri)).await?; + Ok(Response { text }) + } + + /// Sends a GET request to the specified URI with retry logic, returning raw bytes. + /// + /// This method will attempt to fetch the resource up to the configured max_retries, + /// with exponential backoff between each attempt. Useful for fetching binary content + /// like archive files. + pub async fn get_bytes(&self, uri: &str) -> Result, HttpClientError> { + let mut sender = self.create_sender(); + let client = &self.client; + sender.send_bytes(|| client.get(uri)).await } } @@ -92,22 +80,8 @@ impl Response { } } -/// Error type for the HttpClient -#[derive(thiserror::Error, Debug)] -pub enum HttpClientError { - /// Indicates failure to initialize the HTTP client. - #[error("Failed to initilize HTTP client: {0}")] - Initialization(#[source] reqwest::Error), - /// Indicates an HTTP error response received from an endpoint. - #[error("Received error HTTP status: {0}")] - HttpStatus(#[source] reqwest::Error), - /// Indicates a failure to reach the endpoint after 3 attempts. - #[error("Could not reach endpoint after trying 3 times: {0}")] - MaxHttpRetriesReached(#[source] reqwest::Error), - /// Indicates a failure decode the response body to UTF-8 - #[error("Failed to decode the server's response to UTF-8: {0}")] - DecodeResponseUtf8(#[source] reqwest::Error), -} +/// Error type for the HttpClient - re-export from http_utils for compatibility +pub type HttpClientError = HttpRequestError; #[cfg(test)] mod test { @@ -163,19 +137,21 @@ mod test { let response = client.get("0.0.0.0").await; assert!( - matches!(response, Err(HttpClientError::MaxHttpRetriesReached(_))), - "Expected error due to MaxHttpRetriesReached: {response:?}" + matches!(response, Err(HttpClientError::MaxRetriesExceeded)), + "Expected error due to MaxRetriesExceeded: {response:?}" ); } #[tokio::test] - async fn errors_on_http_error_status() { + async fn retries_on_http_error_status_then_fails() { let mut mock_server = Server::new_async().await; + // The new implementation retries on HTTP error status codes too, + // so we expect multiple attempts before MaxRetriesExceeded let mock_endpoint_fut = mock_server .mock("GET", "/.well-known/openid-configuration") .with_status(500) - .expect(1) + .expect_at_least(1) .create_async(); let client = @@ -187,10 +163,68 @@ mod test { let (mock_endpoint, response) = join!(mock_endpoint_fut, client_fut); assert!( - matches!(response, Err(HttpClientError::HttpStatus(_))), - "Expected error due to receiving an http error code: {response:?}" + matches!(response, Err(HttpClientError::MaxRetriesExceeded)), + "Expected error due to MaxRetriesExceeded after retrying on HTTP errors: {response:?}" ); mock_endpoint.assert(); } + + #[tokio::test] + async fn get_bytes_successful_fetch() { + let mut mock_server = Server::new_async().await; + let payload: Vec = vec![1, 2, 3, 4, 5, 6, 7, 8]; + let mock_endpoint = mock_server + .mock("GET", "/binary") + .with_status(200) + .with_header("content-type", "application/octet-stream") + .with_body(payload.clone()) + .expect(1) + .create_async(); + + let client = + HttpClient::new(3, Duration::from_millis(1)).expect("Should create HttpClient."); + let link = &format!("{}/binary", mock_server.url()); + let req_fut = client.get_bytes(link); + let (req_result, mock_result) = join!(req_fut, mock_endpoint); + + let bytes = req_result.expect("Should get bytes"); + assert_eq!(bytes, payload, "Expected bytes to match payload"); + mock_result.assert(); + } + + #[tokio::test] + async fn get_bytes_retries_on_http_error_status() { + let mut mock_server = Server::new_async().await; + + // The new implementation retries on HTTP error status codes too + let mock_endpoint = mock_server + .mock("GET", "/error-binary") + .with_status(500) + .expect_at_least(1) + .create_async(); + + let client = + HttpClient::new(3, Duration::from_millis(1)).expect("Should create HttpClient."); + let link = &format!("{}/error-binary", mock_server.url()); + let req_fut = client.get_bytes(link); + let (req_result, mock_result) = join!(req_fut, mock_endpoint); + + assert!( + matches!(req_result, Err(HttpClientError::MaxRetriesExceeded)), + "Expected MaxRetriesExceeded after retrying on HTTP error status: {req_result:?}" + ); + mock_result.assert(); + } + + #[tokio::test] + async fn get_bytes_max_retries_exceeded() { + let client = + HttpClient::new(3, Duration::from_millis(1)).expect("Should create HttpClient"); + let response = client.get_bytes("0.0.0.0").await; + assert!( + matches!(response, Err(HttpClientError::MaxRetriesExceeded)), + "Expected error due to MaxRetriesExceeded: {response:?}" + ); + } } diff --git a/jans-cedarling/cedarling/src/init/policy_store.rs b/jans-cedarling/cedarling/src/init/policy_store.rs index 4d036fa5cd8..bf5626519c1 100644 --- a/jans-cedarling/cedarling/src/init/policy_store.rs +++ b/jans-cedarling/cedarling/src/init/policy_store.rs @@ -8,7 +8,9 @@ use std::time::Duration; use std::{fs, io}; use crate::bootstrap_config::policy_store_config::{PolicyStoreConfig, PolicyStoreSource}; -use crate::common::policy_store::{AgamaPolicyStore, PolicyStoreWithID}; +use crate::common::policy_store::{ + AgamaPolicyStore, ConversionError, PolicyStoreManager, PolicyStoreWithID, +}; use crate::http::{HttpClient, HttpClientError}; /// Errors that can occur when loading a policy store. @@ -24,6 +26,12 @@ pub enum PolicyStoreLoadError { InvalidStore(String), #[error("Failed to load policy store from {0}: {1}")] ParseFile(Box, io::Error), + #[error("Failed to convert loaded policy store: {0}")] + Conversion(#[from] ConversionError), + #[error("Failed to load policy store from archive: {0}")] + Archive(String), + #[error("Failed to load policy store from directory: {0}")] + Directory(String), } // AgamaPolicyStore contains the structure to accommodate several policies, @@ -47,6 +55,7 @@ fn extract_first_policy_store( .map(|(k, v)| PolicyStoreWithID { id: k.to_owned(), store: v.to_owned(), + metadata: None, // Legacy format doesn't include metadata }) .next(); @@ -90,6 +99,10 @@ pub(crate) async fn load_policy_store( let agama_policy_store = serde_yml::from_str::(&policy_yaml)?; extract_first_policy_store(&agama_policy_store)? }, + PolicyStoreSource::CjarFile(path) => load_policy_store_from_cjar_file(path).await?, + PolicyStoreSource::CjarUrl(url) => load_policy_store_from_cjar_url(url).await?, + PolicyStoreSource::Directory(path) => load_policy_store_from_directory(path).await?, + PolicyStoreSource::ArchiveBytes(bytes) => load_policy_store_from_archive_bytes(bytes)?, }; Ok(policy_store) @@ -106,6 +119,167 @@ async fn load_policy_store_from_lock_master( extract_first_policy_store(&agama_policy_store) } +/// Loads the policy store from a Cedar Archive (.cjar) file. +/// +/// Uses the `load_policy_store_archive` function from the loader module +/// and converts to legacy format for backward compatibility. +#[cfg(not(target_arch = "wasm32"))] +async fn load_policy_store_from_cjar_file( + path: &Path, +) -> Result { + use crate::common::policy_store::loader; + + let loaded = loader::load_policy_store_archive(path).await.map_err(|e| { + PolicyStoreLoadError::Archive(format!("Failed to load from archive: {}", e)) + })?; + + // Get the policy store ID and metadata + let store_id = loaded.metadata.policy_store.id.clone(); + let store_metadata = loaded.metadata.clone(); + + // Convert to legacy format using PolicyStoreManager + let legacy_store = PolicyStoreManager::convert_to_legacy(loaded)?; + + Ok(PolicyStoreWithID { + id: store_id, + store: legacy_store, + metadata: Some(store_metadata), + }) +} + +/// Loads the policy store from a Cedar Archive (.cjar) file. +/// WASM version - file system access is not supported. +#[cfg(target_arch = "wasm32")] +async fn load_policy_store_from_cjar_file( + _path: &Path, +) -> Result { + use crate::common::policy_store::loader; + + // Call the loader stub function to ensure it's used and the error variant is constructed + match loader::load_policy_store_archive(_path).await { + Err(e) => Err(PolicyStoreLoadError::Archive(format!( + "Loading from file path is not supported in WASM. Use CjarUrl instead. Original error: {}", + e + ))), + Ok(_) => unreachable!("WASM stub should always return an error"), + } +} + +/// Loads the policy store from a Cedar Archive (.cjar) URL. +/// +/// Fetches the archive via HTTP, loads it using `load_policy_store_archive_bytes`, +/// and converts to legacy format for backward compatibility. +async fn load_policy_store_from_cjar_url( + url: &str, +) -> Result { + use crate::common::policy_store::loader; + + // Fetch the archive bytes via HTTP + let client = HttpClient::new(3, Duration::from_secs(3))?; + let bytes = client + .get_bytes(url) + .await + .map_err(|e| PolicyStoreLoadError::Archive(format!("Failed to fetch archive: {}", e)))?; + + // Load from bytes (works in both native and WASM) + let loaded = loader::load_policy_store_archive_bytes(bytes).map_err(|e| { + PolicyStoreLoadError::Archive(format!("Failed to load from archive: {}", e)) + })?; + + // Get the policy store ID and metadata + let store_id = loaded.metadata.policy_store.id.clone(); + let store_metadata = loaded.metadata.clone(); + + // Convert to legacy format using PolicyStoreManager + let legacy_store = PolicyStoreManager::convert_to_legacy(loaded)?; + + Ok(PolicyStoreWithID { + id: store_id, + store: legacy_store, + metadata: Some(store_metadata), + }) +} + +/// Loads the policy store from a directory structure. +/// +/// Uses the `load_policy_store_directory` function from the loader module +/// and converts to legacy format for backward compatibility. +#[cfg(not(target_arch = "wasm32"))] +async fn load_policy_store_from_directory( + path: &Path, +) -> Result { + use crate::common::policy_store::loader; + + let loaded = loader::load_policy_store_directory(path) + .await + .map_err(|e| { + PolicyStoreLoadError::Directory(format!("Failed to load from directory: {}", e)) + })?; + + // Get the policy store ID and metadata + let store_id = loaded.metadata.policy_store.id.clone(); + let store_metadata = loaded.metadata.clone(); + + // Convert to legacy format using PolicyStoreManager + let legacy_store = PolicyStoreManager::convert_to_legacy(loaded)?; + + Ok(PolicyStoreWithID { + id: store_id, + store: legacy_store, + metadata: Some(store_metadata), + }) +} + +/// Loads the policy store from a directory structure. +/// WASM version - file system access is not supported. +#[cfg(target_arch = "wasm32")] +async fn load_policy_store_from_directory( + _path: &Path, +) -> Result { + use crate::common::policy_store::loader; + + // Call the loader stub function to ensure it's used and the error variant is constructed + match loader::load_policy_store_directory(_path).await { + Err(e) => Err(PolicyStoreLoadError::Directory(format!( + "Loading from directory is not supported in WASM. Original error: {}", + e + ))), + Ok(_) => unreachable!("WASM stub should always return an error"), + } +} + +/// Loads the policy store directly from archive bytes. +/// +/// This is useful for: +/// - WASM environments with custom fetch logic (e.g., auth headers) +/// - Embedding archives in applications +/// - Loading from non-standard sources (databases, S3, etc.) +/// +/// Works on all platforms including WASM. +fn load_policy_store_from_archive_bytes( + bytes: &[u8], +) -> Result { + use crate::common::policy_store::loader; + + // Load from bytes (works in both native and WASM) + let loaded = loader::load_policy_store_archive_bytes(bytes.to_vec()).map_err(|e| { + PolicyStoreLoadError::Archive(format!("Failed to load from archive bytes: {}", e)) + })?; + + // Get the policy store ID and metadata + let store_id = loaded.metadata.policy_store.id.clone(); + let store_metadata = loaded.metadata.clone(); + + // Convert to legacy format using PolicyStoreManager + let legacy_store = PolicyStoreManager::convert_to_legacy(loaded)?; + + Ok(PolicyStoreWithID { + id: store_id, + store: legacy_store, + metadata: Some(store_metadata), + }) +} + #[cfg(test)] mod test { use std::path::Path; diff --git a/jans-cedarling/cedarling/src/init/service_factory.rs b/jans-cedarling/cedarling/src/init/service_factory.rs index 261c5173f34..e87d54f263b 100644 --- a/jans-cedarling/cedarling/src/init/service_factory.rs +++ b/jans-cedarling/cedarling/src/init/service_factory.rs @@ -11,7 +11,9 @@ use super::service_config::ServiceConfig; use crate::LogLevel; use crate::authz::{Authz, AuthzConfig, AuthzServiceInitError}; use crate::bootstrap_config::BootstrapConfig; -use crate::common::policy_store::{PolicyStoreWithID, TrustedIssuersValidationError}; +use crate::common::policy_store::{ + PolicyStoreMetadata, PolicyStoreWithID, TrustedIssuersValidationError, +}; use crate::entity_builder::*; use crate::jwt::{JwtService, JwtServiceInitError}; use crate::log::interface::LogWriter; @@ -59,6 +61,14 @@ impl<'a> ServiceFactory<'a> { Ok(&self.service_config.policy_store) } + /// Get the policy store metadata if available. + /// + /// Metadata is only available when the policy store is loaded from the new + /// directory/archive format. Legacy JSON/YAML formats do not include metadata. + pub fn policy_store_metadata(&self) -> Option<&PolicyStoreMetadata> { + self.service_config.policy_store.metadata.as_ref() + } + // get log service pub fn log_service(&mut self) -> log::Logger { self.log_service.clone() @@ -91,8 +101,11 @@ impl<'a> ServiceFactory<'a> { // Log warns that some default entities loaded not correctly // it will be logged only once. for warn in default_entities_with_warn.warns() { - let log_entry = LogEntry::new(BaseLogEntry::new_system_opt_request_id(LogLevel::WARN, None)) - .set_message(warn.to_string()); + let log_entry = LogEntry::new(BaseLogEntry::new_system_opt_request_id( + LogLevel::WARN, + None, + )) + .set_message(warn.to_string()); logger.log_any(log_entry); } diff --git a/jans-cedarling/cedarling/src/jwt/mod.rs b/jans-cedarling/cedarling/src/jwt/mod.rs index a4e3003424c..3194f1c903c 100644 --- a/jans-cedarling/cedarling/src/jwt/mod.rs +++ b/jans-cedarling/cedarling/src/jwt/mod.rs @@ -76,13 +76,14 @@ mod token_cache; mod validation; #[cfg(test)] -#[allow(dead_code)] -mod test_utils; +pub(crate) mod test_utils; pub use decode::*; pub use error::*; pub use token::{Token, TokenClaimTypeError, TokenClaims}; pub use token_cache::TokenCache; +// Re-export trusted issuer validation for public API +pub use validation::{TrustedIssuerError, TrustedIssuerValidator, validate_required_claims}; use crate::JwtConfig; use crate::LogLevel; @@ -100,7 +101,6 @@ use serde_json::json; use status_list::*; use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use std::sync::RwLock; use validation::*; /// The value of the `iss` claim from a JWT @@ -111,6 +111,8 @@ pub struct JwtService { validators: JwtValidatorCache, key_service: Arc, issuer_configs: HashMap, + /// Trusted issuer validator for advanced validation scenarios + trusted_issuer_validator: TrustedIssuerValidator, logger: Option, token_cache: TokenCache, signed_authz_available: bool, @@ -126,6 +128,18 @@ struct IssuerConfig { } impl JwtService { + /// Creates a new JWT service with the given configuration. + /// + /// # Arguments + /// + /// * `jwt_config` - JWT validation configuration (signature validation, algorithms, etc.) + /// * `trusted_issuers` - Optional map of trusted issuer configurations from the policy store + /// * `logger` - Optional logger for diagnostic messages + /// * `token_cache_max_ttl_sec` - Maximum TTL for cached validated tokens (0 to disable caching) + /// + /// # Errors + /// + /// Returns `JwtServiceInitError` if initialization fails (e.g., failed to fetch OIDC config) pub async fn new( jwt_config: &JwtConfig, trusted_issuers: Option>, @@ -146,6 +160,9 @@ impl JwtService { let trusted_issuers = trusted_issuers.unwrap_or_default(); let has_trusted_issuers = !trusted_issuers.is_empty(); + // Clone trusted_issuers before consumption - original is iterated and consumed below + let trusted_issuers_for_validator = trusted_issuers.clone(); + for (issuer_id, iss) in trusted_issuers.into_iter() { // this is what we expect to find in the JWT `iss` claim let mut iss_claim = iss.oidc_endpoint.origin().ascii_serialization(); @@ -199,10 +216,15 @@ impl JwtService { } let key_service = Arc::new(key_service); + // Create TrustedIssuerValidator for advanced validation scenarios + let trusted_issuer_validator = + TrustedIssuerValidator::with_logger(trusted_issuers_for_validator, logger.clone()); + Ok(Self { validators, key_service, issuer_configs, + trusted_issuer_validator, logger, token_cache, signed_authz_available, @@ -210,6 +232,22 @@ impl JwtService { }) } + /// Validates multiple JWT tokens against trusted issuers. + /// + /// This method validates each token in the provided map, checking: + /// - JWT signature validation (if enabled) + /// - Token expiration and other standard claims + /// - Required claims as specified in the trusted issuer configuration + /// + /// Tokens from untrusted issuers are skipped with a warning. + /// + /// # Arguments + /// + /// * `tokens` - Map of token names to JWT strings (e.g., "access_token" -> "eyJ...") + /// + /// # Returns + /// + /// Map of token names to validated `Token` objects, or an error if any token fails validation. pub async fn validate_tokens<'a>( &'a self, tokens: &'a HashMap, @@ -292,10 +330,10 @@ impl JwtService { token_kind, algorithm: decoded_jwt.header.alg, }; - let validator: Arc> = self - .validators - .get(&validator_key) - .ok_or(ValidateJwtError::MissingValidator(validator_key.owned()))?; + let validator: Arc> = + self.validators + .get(&validator_key) + .ok_or(ValidateJwtError::MissingValidator(validator_key.owned()))?; // validate JWT // NOTE: the JWT will be validated depending on the validator's settings that @@ -307,9 +345,84 @@ impl JwtService { .validate_jwt(jwt, decoding_key)? }; - // The users of the validated JWT will need a reference to the TrustedIssuer - // to do some processing so we include it here for convenience - validated_jwt.trusted_iss = decoded_jwt.iss().and_then(|iss| self.get_issuer_ref(iss)); + // Use TrustedIssuerValidator to find and validate against trusted issuer + // This implements Requirement 5: "WHEN processing JWT tokens THEN the Cedarling + // SHALL check if the token issuer matches any configured trusted issuers" + let iss_claim = decoded_jwt.iss(); + + // Try to find trusted issuer using TrustedIssuerValidator + let trusted_iss = if let Some(iss) = iss_claim { + match self.trusted_issuer_validator.find_trusted_issuer(iss) { + Ok(issuer) => Some(issuer), + Err(TrustedIssuerError::UntrustedIssuer(_)) => { + // Fall back to issuer_configs for backward compatibility + self.logger.log_any(JwtLogEntry::new( + format!("Untrusted issuer '{}', falling back to issuer_configs", iss), + Some(LogLevel::DEBUG), + )); + self.get_issuer_ref(iss) + }, + Err(e) => { + self.logger.log_any(JwtLogEntry::new( + format!( + "Error finding trusted issuer '{}': {}, falling back to issuer_configs", + iss, e + ), + Some(LogLevel::DEBUG), + )); + self.get_issuer_ref(iss) + }, + } + } else { + None + }; + + // Set trusted issuer reference on validated JWT + validated_jwt.trusted_iss = trusted_iss.clone(); + + // Validate required claims based on trusted issuer configuration + // This implements Requirement 5: "WHEN a JWT token is from a trusted issuer + // THEN the Cedarling SHALL validate required claims as specified in the issuer configuration" + if let Some(trusted_iss) = &trusted_iss { + // Get the token type name from token_kind (skip for StatusList tokens) + let token_type: Option<&str> = match &token_kind { + TokenKind::AuthzRequestInput(name) => Some(*name), + TokenKind::AuthorizeMultiIssuer(name) => Some(name), + TokenKind::StatusList => None, // Skip required claims validation for status list tokens + }; + + if let Some(token_type) = token_type { + // Get token metadata for this token type + if let Some(token_metadata) = trusted_iss.token_metadata.get(token_type) { + // NOTE: This is the ONLY place where trusted-issuer-driven "required claims" + // validation occurs. Standard JWT validation (signature, expiration, + // audience, etc.) happens earlier in the validation pipeline (via the + // JWT validator). The policy-driven required_claims are validated only + // here, once per token, after we've resolved the TrustedIssuer and + // token_metadata for that token type. + if let Err(err) = + validate_required_claims(&validated_jwt.claims, token_type, token_metadata) + { + self.logger.log_any(JwtLogEntry::new( + format!( + "Token '{}' failed required claims validation: {}", + token_type, err + ), + Some(LogLevel::ERROR), + )); + // Convert TrustedIssuerError to ValidateJwtError + match err { + TrustedIssuerError::MissingRequiredClaim { claim, .. } => { + return Err(ValidateJwtError::MissingClaims(vec![claim])); + }, + _ => { + return Err(ValidateJwtError::TrustedIssuerValidation(err)); + }, + } + } + } + } + } Ok(validated_jwt) } @@ -600,7 +713,6 @@ mod test { None, ) .await - .inspect_err(|e| eprintln!("error msg: {}", e)) .expect("Should create JwtService"); let iss = Arc::new(iss); diff --git a/jans-cedarling/cedarling/src/jwt/status_list.rs b/jans-cedarling/cedarling/src/jwt/status_list.rs index 8e9f38a40de..2c6e8cd46ac 100644 --- a/jans-cedarling/cedarling/src/jwt/status_list.rs +++ b/jans-cedarling/cedarling/src/jwt/status_list.rs @@ -331,7 +331,7 @@ mod test { status_list.sub, server.status_list_endpoint().unwrap().to_string() ); - assert_eq!(status_list.ttl, Some(600)); + assert_eq!(status_list.ttl, Some(300)); assert_eq!( status_list.status_list, StatusListClaim { diff --git a/jans-cedarling/cedarling/src/jwt/test_utils.rs b/jans-cedarling/cedarling/src/jwt/test_utils.rs index 57ab1c742ce..04d1997a16c 100644 --- a/jans-cedarling/cedarling/src/jwt/test_utils.rs +++ b/jans-cedarling/cedarling/src/jwt/test_utils.rs @@ -4,7 +4,6 @@ // Copyright (c) 2024, Gluu, Inc. use std::sync::LazyLock; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; use super::http_utils::OpenIdConfig; use super::status_list::{self, StatusBitSize}; @@ -148,13 +147,6 @@ impl MockEndpoints { status_list: None, } } - - #[track_caller] - pub fn assert(&self) { - if let Some(x) = self.oidc.as_ref() { x.assert() } - if let Some(x) = self.jwks.as_ref() { x.assert() } - if let Some(x) = self.status_list.as_ref() { x.assert() } - } } #[derive(Clone, Copy)] @@ -265,14 +257,9 @@ impl MockServer { }; let encoding_key = self.keys.encoding_key.clone(); let build_jwt_claims = move || { - let iat = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); - let exp = iat + Duration::from_secs(3600); // defaults to 1 hour - let ttl = ttl - .map(Duration::from_secs) - .unwrap_or_else(|| - // defaults to 5 mins if the ttl is None - Duration::from_secs(600) - ); + let now = chrono::Utc::now().timestamp(); + let exp = now + 3600; // defaults to 1 hour + let ttl_secs = ttl.unwrap_or(300); // defaults to 5 mins if the ttl is None let claims = json!({ "sub": sub, "status_list": { @@ -280,9 +267,9 @@ impl MockServer { "lst": lst, }, "iss": iss, - "exp": exp.as_secs(), - "ttl": ttl.as_secs(), - "iat": iat.as_secs(), + "exp": exp, + "ttl": ttl_secs, + "iat": now, }); jwt::encode(&header, &claims, &encoding_key) diff --git a/jans-cedarling/cedarling/src/jwt/validation.rs b/jans-cedarling/cedarling/src/jwt/validation.rs index 3af0a8c6819..9920b068721 100644 --- a/jans-cedarling/cedarling/src/jwt/validation.rs +++ b/jans-cedarling/cedarling/src/jwt/validation.rs @@ -3,8 +3,12 @@ // // Copyright (c) 2024, Gluu, Inc. +mod trusted_issuer_validator; mod validator; mod validator_cache; +pub use trusted_issuer_validator::{ + TrustedIssuerError, TrustedIssuerValidator, validate_required_claims, +}; pub use validator::*; pub use validator_cache::*; diff --git a/jans-cedarling/cedarling/src/jwt/validation/trusted_issuer_validator.rs b/jans-cedarling/cedarling/src/jwt/validation/trusted_issuer_validator.rs new file mode 100644 index 00000000000..d99481705aa --- /dev/null +++ b/jans-cedarling/cedarling/src/jwt/validation/trusted_issuer_validator.rs @@ -0,0 +1,1050 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Trusted issuer JWT validation module. +//! +//! This module provides standalone functionality to validate JWT tokens against configured +//! trusted issuers, including issuer matching, required claims validation, JWKS fetching, +//! and signature verification. +//! +//! ## Features +//! +//! - **Issuer matching**: Validates that JWT tokens come from configured trusted issuers +//! - **Required claims validation**: Ensures tokens contain all claims specified in issuer configuration +//! - **JWKS management**: Fetches and caches JWKS keys from issuer's OIDC endpoint with configurable TTL +//! - **Signature verification**: Validates JWT signatures using cached JWKS keys + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; +use serde_json::Value as JsonValue; +use thiserror::Error; +use url::Url; + +use crate::common::policy_store::{TokenEntityMetadata, TrustedIssuer}; +use crate::jwt::JwtLogEntry; +use crate::jwt::http_utils::{GetFromUrl, OpenIdConfig}; +use crate::jwt::key_service::{DecodingKeyInfo, KeyService, KeyServiceError}; +use crate::log::Logger; +use crate::log::interface::LogWriter; + +/// Errors that can occur during trusted issuer validation. +#[derive(Debug, Error)] +pub enum TrustedIssuerError { + /// Token issuer is not in the list of trusted issuers + #[error("Untrusted issuer: '{0}'")] + UntrustedIssuer(String), + + /// Token is missing a required claim + #[error("Missing required claim: '{claim}' for token type '{token_type}'")] + MissingRequiredClaim { + /// The name of the missing claim + claim: String, + /// The type of token being validated + token_type: String, + }, + + /// Failed to decode JWT header + #[error("Failed to decode JWT header: {0}")] + DecodeHeader(#[from] jsonwebtoken::errors::Error), + + /// Failed to fetch OpenID configuration + #[error("Failed to fetch OpenID configuration from '{endpoint}': {source}")] + OpenIdConfigFetch { + /// The OIDC endpoint that failed + endpoint: String, + /// The underlying error + #[source] + source: Box, + }, + + /// Failed to fetch or process JWKS + #[error("Failed to fetch JWKS: {0}")] + JwksFetch(#[from] KeyServiceError), + + /// No matching key found in JWKS + #[error( + "No matching key found for kid: {}, algorithm: '{alg:?}'", + kid.as_ref().map(|s| s.as_str()).unwrap_or("none") + )] + NoMatchingKey { + /// The key ID from the JWT header + kid: Option, + /// The algorithm from the JWT header + alg: Algorithm, + }, + + /// JWT signature validation failed + #[error("Invalid JWT signature: {0}")] + InvalidSignature(String), + + /// Token type not configured for issuer + #[error("Token type '{token_type}' not configured for issuer '{issuer}'")] + TokenTypeNotConfigured { + /// The token type that wasn't configured + token_type: String, + /// The issuer missing the token type configuration + issuer: String, + }, + + /// Missing issuer claim in token + #[error("Token missing 'iss' claim")] + MissingIssuerClaim, + + /// Token metadata has empty entity_type_name + #[error( + "Invalid token metadata configuration: entity_type_name is empty for token type '{token_type}'" + )] + EmptyEntityTypeName { + /// The token type with empty entity_type_name + token_type: String, + }, +} + +/// Result type for trusted issuer validation operations. +pub type Result = std::result::Result; + +/// Default JWKS cache duration (1 hour) used when no Cache-Control header is present +const DEFAULT_JWKS_CACHE_DURATION_SECS: u64 = 3600; + +/// Minimum JWKS cache duration (5 minutes) to prevent excessive requests +const MIN_JWKS_CACHE_DURATION_SECS: u64 = 300; + +/// Maximum JWKS cache duration (24 hours) to ensure keys are refreshed regularly +const MAX_JWKS_CACHE_DURATION_SECS: u64 = 86400; + +/// Validator for JWT tokens against trusted issuer configurations. +/// +/// This validator provides the following functionality: +/// - Issuer matching against configured trusted issuers +/// - Required claims validation based on token metadata +/// - JWKS fetching and caching with configurable TTL +/// - JWT signature verification +pub struct TrustedIssuerValidator { + /// Map of issuer identifiers to their configurations + trusted_issuers: HashMap>, + /// Reverse lookup map: OIDC base URL -> issuer + /// This optimizes issuer lookup when dealing with hundreds of trusted issuers + url_to_issuer: HashMap>, + /// Key service for managing JWKS keys + key_service: KeyService, + /// Cache of fetched OpenID configurations (issuer URL -> config) + oidc_configs: HashMap>, + /// Timestamp of last JWKS fetch for expiration tracking + /// Maps issuer OIDC endpoint to (fetch_time, cache_duration) + keys_fetch_time: HashMap, Duration)>, + /// Optional logger for diagnostic messages + logger: Option, +} + +impl TrustedIssuerValidator { + /// Creates a new trusted issuer validator with the given trusted issuers. + /// + /// This is a convenience constructor equivalent to `with_logger(trusted_issuers, None)`. + /// + /// # Arguments + /// + /// * `trusted_issuers` - Map of issuer IDs to their configurations + pub fn new(trusted_issuers: HashMap) -> Self { + Self::with_logger(trusted_issuers, None) + } + + /// Creates a new trusted issuer validator with a logger. + pub fn with_logger( + trusted_issuers: HashMap, + logger: Option, + ) -> Self { + let trusted_issuers: HashMap> = trusted_issuers + .into_iter() + .map(|(k, v)| (k, Arc::new(v))) + .collect(); + + // Build reverse lookup map: OIDC base URL -> issuer + let mut url_to_issuer = HashMap::with_capacity(trusted_issuers.len()); + for (id, issuer) in &trusted_issuers { + // Extract base URL from OIDC endpoint + if let Some(base_url) = issuer + .oidc_endpoint + .as_str() + .strip_suffix("/.well-known/openid-configuration") + { + let normalized_url = base_url.trim_end_matches('/'); + url_to_issuer.insert(normalized_url.to_string(), issuer.clone()); + } + + // Also add the issuer ID if it's a URL format + if id.starts_with("http://") || id.starts_with("https://") { + let normalized_id = id.trim_end_matches('/'); + url_to_issuer.insert(normalized_id.to_string(), issuer.clone()); + } + } + + Self { + trusted_issuers, + url_to_issuer, + key_service: KeyService::new(), + oidc_configs: HashMap::new(), + keys_fetch_time: HashMap::new(), + logger, + } + } + + /// Finds a trusted issuer by the issuer claim value. + /// + /// This method matches the token's `iss` claim against the configured trusted issuers. + /// The matching is done by comparing the issuer URL or issuer ID. + pub fn find_trusted_issuer(&self, issuer_claim: &str) -> Result> { + // Try exact match first by issuer ID + if let Some(issuer) = self.trusted_issuers.get(issuer_claim) { + return Ok(issuer.clone()); + } + + // Try matching by URL using reverse lookup map (O(1) instead of O(n)) + // Parse the issuer claim as a URL and normalize it for lookup + if let Ok(iss_url) = Url::parse(issuer_claim) { + let normalized_url = iss_url.as_str().trim_end_matches('/'); + + if let Some(issuer) = self.url_to_issuer.get(normalized_url) { + return Ok(issuer.clone()); + } + } + + Err(TrustedIssuerError::UntrustedIssuer( + issuer_claim.to_string(), + )) + } + + /// Fetches and caches the OpenID configuration for a trusted issuer. + /// + /// If the configuration has already been fetched, returns the cached version. + async fn get_or_fetch_oidc_config( + &mut self, + trusted_issuer: &TrustedIssuer, + ) -> Result> { + let endpoint_str = trusted_issuer.oidc_endpoint.as_str(); + + // Check cache first + if let Some(config) = self.oidc_configs.get(endpoint_str) { + return Ok(config.clone()); + } + + // Fetch from endpoint + let config = OpenIdConfig::get_from_url(&trusted_issuer.oidc_endpoint) + .await + .map_err(|e| TrustedIssuerError::OpenIdConfigFetch { + endpoint: endpoint_str.to_string(), + source: Box::new(e), + })?; + + let config_arc = Arc::new(config); + self.oidc_configs + .insert(endpoint_str.to_string(), Arc::clone(&config_arc)); + + Ok(config_arc) + } + + /// Ensures JWKS keys are loaded for the given issuer. + /// + /// Fetches the OpenID configuration and loads keys from the JWKS endpoint. + /// Implements automatic key refresh based on cache duration. + async fn ensure_keys_loaded(&mut self, trusted_issuer: &TrustedIssuer) -> Result<()> { + let oidc_config = self.get_or_fetch_oidc_config(trusted_issuer).await?; + let endpoint_str = trusted_issuer.oidc_endpoint.as_str(); + + // Check if keys for this endpoint have been loaded and if they've expired + // Use endpoint-specific check instead of global has_keys() to avoid skipping issuers + let should_refresh = if let Some((fetch_time, cache_duration)) = + self.keys_fetch_time.get(endpoint_str) + { + // Keys have been loaded for this endpoint - check if they've expired + let elapsed = Utc::now().signed_duration_since(*fetch_time); + // Refresh if elapsed time exceeds cache duration + // Note: chrono::Duration can represent negative values if time went backwards + elapsed + >= chrono::Duration::from_std(*cache_duration).unwrap_or(chrono::Duration::zero()) + } else { + // No timestamp recorded for this endpoint - keys haven't been loaded yet + true + }; + + if !should_refresh { + return Ok(()); + } + + // Fetch keys using the key service + self.key_service + .get_keys_using_oidc(&oidc_config, &self.logger) + .await?; + + // Determine cache duration + let cache_duration = self.determine_cache_duration(trusted_issuer); + + // Record fetch time for expiration tracking + self.keys_fetch_time + .insert(endpoint_str.to_string(), (Utc::now(), cache_duration)); + + // Log key refresh for monitoring + self.logger.log_any(JwtLogEntry::new( + format!( + "JWKS keys loaded for issuer '{}', cache duration: {}s", + endpoint_str, + cache_duration.as_secs() + ), + Some(crate::LogLevel::INFO), + )); + + Ok(()) + } + + /// Determines the appropriate cache duration for JWKS keys. + fn determine_cache_duration(&self, _trusted_issuer: &TrustedIssuer) -> Duration { + let cache_secs = DEFAULT_JWKS_CACHE_DURATION_SECS; + + let bounded_secs = + cache_secs.clamp(MIN_JWKS_CACHE_DURATION_SECS, MAX_JWKS_CACHE_DURATION_SECS); + + Duration::from_secs(bounded_secs) + } + + /// Validates that a token contains all required claims based on token metadata. + /// + /// This is a convenience method that delegates to the standalone `validate_required_claims` function. + /// It validates claims explicitly specified in `required_claims` set from the token metadata. + pub fn validate_required_claims( + &self, + claims: &JsonValue, + token_type: &str, + token_metadata: &TokenEntityMetadata, + ) -> Result<()> { + validate_required_claims(claims, token_type, token_metadata) + } +} + +/// Validates that a token contains all required claims based on token metadata. +/// +/// This is a standalone function that can be used independently of `TrustedIssuerValidator`. +/// It validates: +/// - `entity_type_name` is not empty (configuration validation) +/// - All claims in `required_claims` set exist +/// +/// Note: Mapping fields like `user_id`, `role_mapping`, `workload_id`, and `token_id` +/// are configuration hints for claim extraction, not strictly required claims. +/// They are validated only if explicitly included in `required_claims`. +/// +/// # Arguments +/// +/// * `claims` - The JWT claims as a JSON value +/// * `token_type` - The type of token (e.g., "access_token", "id_token") +/// * `token_metadata` - The token metadata configuration from the trusted issuer +/// +pub fn validate_required_claims( + claims: &JsonValue, + token_type: &str, + token_metadata: &TokenEntityMetadata, +) -> Result<()> { + // Check for entity_type_name (configuration validation, always required) + if token_metadata.entity_type_name.is_empty() { + return Err(TrustedIssuerError::EmptyEntityTypeName { + token_type: token_type.to_string(), + }); + } + + // Validate all claims explicitly specified in required_claims set + // This is the authoritative list of required claims from the issuer configuration + for claim in &token_metadata.required_claims { + if claims.get(claim).is_none() { + return Err(TrustedIssuerError::MissingRequiredClaim { + claim: claim.clone(), + token_type: token_type.to_string(), + }); + } + } + + Ok(()) +} + +impl TrustedIssuerValidator { + /// Validates a JWT token against a trusted issuer with JWKS preloading. + /// + /// This performs comprehensive validation including: + /// 1. Extracts the issuer claim from the token + /// 2. Matches the issuer against configured trusted issuers + /// 3. Preloads JWKS if not already cached + /// 4. Validates the JWT signature using JWKS + /// 5. Validates required claims based on token metadata + /// 6. Validates exp/nbf claims if present + /// + /// Returns the validated claims and the matched trusted issuer. + pub async fn preload_and_validate_token( + &mut self, + token: &str, + token_type: &str, + ) -> Result<(JsonValue, Arc)> { + // Decode the JWT header to get the key ID and algorithm + let header = decode_header(token)?; + + // First, we need to decode without verification to get the issuer claim + // and check for exp/nbf to configure validation later + let mut validation = Validation::new(header.alg); + validation.insecure_disable_signature_validation(); + validation.validate_exp = false; + validation.validate_nbf = false; + validation.required_spec_claims.clear(); + + let unverified_token = decode::( + token, + &DecodingKey::from_secret(&[]), // Dummy key since we disabled validation + &validation, + )?; + + let has_exp = unverified_token.claims.get("exp").is_some(); + let has_nbf = unverified_token.claims.get("nbf").is_some(); + + // Extract issuer claim + let issuer_claim = unverified_token + .claims + .get("iss") + .and_then(|v| v.as_str()) + .ok_or(TrustedIssuerError::MissingIssuerClaim)?; + + // Find the trusted issuer + let trusted_issuer = self.find_trusted_issuer(issuer_claim)?; + + // Get token metadata for this token type + let token_metadata = trusted_issuer + .token_metadata + .get(token_type) + .ok_or_else(|| TrustedIssuerError::TokenTypeNotConfigured { + token_type: token_type.to_string(), + issuer: issuer_claim.to_string(), + })?; + + // Check if token is trusted + if !token_metadata.trusted { + return Err(TrustedIssuerError::UntrustedIssuer( + issuer_claim.to_string(), + )); + } + + // Ensure JWKS keys are loaded for this issuer + self.ensure_keys_loaded(&trusted_issuer).await?; + + // Now validate the signature + let key_info = DecodingKeyInfo { + issuer: Some(issuer_claim.to_string()), + kid: header.kid.clone(), + algorithm: header.alg, + }; + + let decoding_key = self.key_service.get_key(&key_info).ok_or_else(|| { + TrustedIssuerError::NoMatchingKey { + kid: header.kid, + alg: header.alg, + } + })?; + + // Create validation with signature checking enabled + let mut validation = Validation::new(header.alg); + validation.set_issuer(&[issuer_claim]); + + validation.validate_exp = has_exp; + validation.validate_nbf = has_nbf; + + validation.required_spec_claims.clear(); + validation.validate_aud = false; + + // Decode and validate signature + let verified_token = decode::(token, decoding_key, &validation) + .map_err(|e| TrustedIssuerError::InvalidSignature(e.to_string()))?; + + // Validate required claims (after signature verification) + self.validate_required_claims(&verified_token.claims, token_type, token_metadata)?; + + Ok((verified_token.claims, trusted_issuer)) + } + + /// Gets a reference to the key service for JWKS management. + pub fn key_service(&self) -> &KeyService { + &self.key_service + } + + /// Gets a mutable reference to the key service for JWKS management. + pub fn key_service_mut(&mut self) -> &mut KeyService { + &mut self.key_service + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::policy_store::TokenEntityMetadata; + use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine}; + use std::collections::HashSet; + + fn create_test_issuer(id: &str, endpoint: &str) -> TrustedIssuer { + let mut token_metadata = HashMap::new(); + token_metadata.insert( + "access_token".to_string(), + TokenEntityMetadata::access_token(), + ); + token_metadata.insert("id_token".to_string(), TokenEntityMetadata::id_token()); + + TrustedIssuer { + name: format!("Test Issuer {}", id), + description: "Test issuer for validation".to_string(), + oidc_endpoint: Url::parse(endpoint).unwrap(), + token_metadata, + } + } + + fn create_test_issuer_with_metadata( + id: &str, + endpoint: &str, + metadata: HashMap, + ) -> TrustedIssuer { + TrustedIssuer { + name: format!("Test Issuer {}", id), + description: "Test issuer for validation".to_string(), + oidc_endpoint: Url::parse(endpoint).unwrap(), + token_metadata: metadata, + } + } + + #[test] + fn test_find_trusted_issuer_by_id() { + let issuers = HashMap::from([ + ( + "issuer1".to_string(), + create_test_issuer("1", "https://issuer1.com/.well-known/openid-configuration"), + ), + ( + "issuer2".to_string(), + create_test_issuer("2", "https://issuer2.com/.well-known/openid-configuration"), + ), + ]); + + let validator = TrustedIssuerValidator::new(issuers); + + let result = validator.find_trusted_issuer("issuer1"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().name, "Test Issuer 1"); + } + + #[test] + fn test_find_trusted_issuer_by_url() { + let issuers = HashMap::from([( + "issuer1".to_string(), + create_test_issuer("1", "https://issuer1.com/.well-known/openid-configuration"), + )]); + + let validator = TrustedIssuerValidator::new(issuers); + + let result = validator.find_trusted_issuer("https://issuer1.com"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().name, "Test Issuer 1"); + } + + #[test] + fn test_untrusted_issuer() { + let issuers = HashMap::from([( + "issuer1".to_string(), + create_test_issuer("1", "https://issuer1.com/.well-known/openid-configuration"), + )]); + + let validator = TrustedIssuerValidator::new(issuers); + + let result = validator.find_trusted_issuer("https://evil.com"); + assert!( + matches!(result.unwrap_err(), TrustedIssuerError::UntrustedIssuer(_)), + "expected UntrustedIssuer error" + ); + } + + #[test] + fn test_validate_required_claims_success() { + let validator = TrustedIssuerValidator::new(HashMap::new()); + + let claims = serde_json::json!({ + "sub": "user123", + "jti": "token123", + "role": "admin" + }); + + let metadata = TokenEntityMetadata::builder() + .entity_type_name("Jans::Access_token".to_string()) + .user_id(Some("sub".to_string())) + .role_mapping(Some("role".to_string())) + .token_id("jti".to_string()) + .build(); + + let result = validator.validate_required_claims(&claims, "access_token", &metadata); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_required_claims_missing_sub() { + let validator = TrustedIssuerValidator::new(HashMap::new()); + + let claims = serde_json::json!({ + "jti": "token123", + "role": "admin" + }); + + // Only claims in required_claims are validated + let metadata = TokenEntityMetadata::builder() + .entity_type_name("Jans::Access_token".to_string()) + .user_id(Some("sub".to_string())) // This is just a mapping, not validated + .token_id("jti".to_string()) + .required_claims(HashSet::from(["sub".to_string()])) // This IS validated + .build(); + + let result = validator.validate_required_claims(&claims, "access_token", &metadata); + assert!( + matches!( + result.unwrap_err(), + TrustedIssuerError::MissingRequiredClaim { claim, .. } if claim == "sub" + ), + "expected MissingRequiredClaim error for 'sub'" + ); + } + + #[test] + fn test_validate_required_claims_missing_role() { + let validator = TrustedIssuerValidator::new(HashMap::new()); + + let claims = serde_json::json!({ + "sub": "user123", + "jti": "token123" + }); + + // Only claims in required_claims are validated + let metadata = TokenEntityMetadata::builder() + .entity_type_name("Jans::Access_token".to_string()) + .role_mapping(Some("role".to_string())) // This is just a mapping, not validated + .token_id("jti".to_string()) + .required_claims(HashSet::from(["role".to_string()])) // This IS validated + .build(); + + let result = validator.validate_required_claims(&claims, "access_token", &metadata); + assert!( + matches!( + result.unwrap_err(), + TrustedIssuerError::MissingRequiredClaim { claim, .. } if claim == "role" + ), + "expected MissingRequiredClaim error for 'role'" + ); + } + + #[test] + fn test_validate_required_claims_missing_jti() { + let validator = TrustedIssuerValidator::new(HashMap::new()); + + let claims = serde_json::json!({ + "sub": "user123", + "role": "admin" + }); + + // Only claims in required_claims are validated + let metadata = TokenEntityMetadata::builder() + .entity_type_name("Jans::Access_token".to_string()) + .token_id("jti".to_string()) // This is just a mapping, not validated + .required_claims(HashSet::from(["jti".to_string()])) // This IS validated + .build(); + + let result = validator.validate_required_claims(&claims, "access_token", &metadata); + assert!( + matches!( + result.unwrap_err(), + TrustedIssuerError::MissingRequiredClaim { claim, .. } if claim == "jti" + ), + "expected MissingRequiredClaim error for 'jti'" + ); + } + + #[test] + fn test_validate_required_claims_mapping_fields_not_required() { + // Test that mapping fields (user_id, role_mapping, token_id) are NOT validated + // unless they are explicitly in required_claims + let validator = TrustedIssuerValidator::new(HashMap::new()); + + let claims = serde_json::json!({ + "iss": "https://issuer.com" + // Note: sub, role, jti are all missing + }); + + let metadata = TokenEntityMetadata::builder() + .entity_type_name("Jans::Access_token".to_string()) + .user_id(Some("sub".to_string())) // Mapping only + .role_mapping(Some("role".to_string())) // Mapping only + .token_id("jti".to_string()) // Mapping only + .required_claims(HashSet::new()) // No required claims + .build(); + + // Should pass because required_claims is empty + let result = validator.validate_required_claims(&claims, "access_token", &metadata); + assert!(result.is_ok()); + } + + /// Helper to create a test JWT token with given claims and key + #[cfg(test)] + fn create_test_jwt(claims: &serde_json::Value, kid: &str, algorithm: Algorithm) -> String { + use jsonwebtoken::{EncodingKey, Header, encode}; + + let mut header = Header::new(algorithm); + header.kid = Some(kid.to_string()); + + let key = EncodingKey::from_secret(b"test_secret_key"); + + encode(&header, claims, &key).expect("Failed to create test JWT") + } + + #[tokio::test] + async fn test_get_or_fetch_oidc_config_caching() { + let mut server = mockito::Server::new_async().await; + let oidc_url = format!("{}/.well-known/openid-configuration", server.url()); + + // Mock the OIDC configuration endpoint + let mock = server + .mock("GET", "/.well-known/openid-configuration") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::json!({ + "issuer": server.url(), + "jwks_uri": format!("{}/jwks", server.url()), + }).to_string()) + .expect(1) // Should only be called once due to caching + .create_async() + .await; + + let issuer = create_test_issuer("test", &oidc_url); + let mut validator = TrustedIssuerValidator::new(HashMap::new()); + + // First fetch - should call the endpoint + let config1 = validator.get_or_fetch_oidc_config(&issuer).await; + assert!(config1.is_ok(), "First fetch should succeed"); + + // Second fetch - should use cache (mock expects only 1 call) + let config2 = validator.get_or_fetch_oidc_config(&issuer).await; + assert!(config2.is_ok(), "Second fetch should succeed from cache"); + + // Verify same Arc + assert!(Arc::ptr_eq(&config1.unwrap(), &config2.unwrap())); + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_get_or_fetch_oidc_config_invalid_endpoint() { + let invalid_url = "https://invalid-endpoint-that-does-not-exist.example.com/.well-known/openid-configuration"; + let issuer = create_test_issuer("test", invalid_url); + let mut validator = TrustedIssuerValidator::new(HashMap::new()); + + let result = validator.get_or_fetch_oidc_config(&issuer).await; + assert!(result.is_err()); + if let Err(err) = result { + assert!(matches!(err, TrustedIssuerError::OpenIdConfigFetch { .. })); + } + } + + #[tokio::test] + async fn test_ensure_keys_loaded_success() { + let mut server = mockito::Server::new_async().await; + let oidc_url = format!("{}/.well-known/openid-configuration", server.url()); + + // Mock OIDC configuration + let _oidc_mock = server + .mock("GET", "/.well-known/openid-configuration") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + serde_json::json!({ + "issuer": server.url(), + "jwks_uri": format!("{}/jwks", server.url()), + }) + .to_string(), + ) + .create_async() + .await; + + // Mock JWKS endpoint with a test key + let _jwks_mock = server + .mock("GET", "/jwks") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::json!({ + "keys": [{ + "kty": "RSA", + "kid": "test=-key-1", + "use": "sig", + "alg": "RS256", + "n": "xGOr-H7A-PWR8nRExwEPEe8spD9FwPJSq2KsuJFQH5JvFvOsKNgLvXX6BxJwDAj9K7rZHvqcL4aJkGDVpYE_1x4zAFXgSzYTqQVq0Ts", + "e": "AQAB" + }] + }).to_string()) + .create_async() + .await; + + let issuer = create_test_issuer("test", &oidc_url); + let mut validator = TrustedIssuerValidator::new(HashMap::new()); + + let result = validator.ensure_keys_loaded(&issuer).await; + assert!(result.is_ok(), "Keys should be loaded successfully"); + assert!( + validator.key_service().has_keys(), + "Key service should have keys" + ); + } + + #[tokio::test] + async fn test_validate_token_untrusted_issuer() { + let mut validator = TrustedIssuerValidator::new(HashMap::from([( + "issuer1".to_string(), + create_test_issuer("1", "https://issuer1.com/.well-known/openid-configuration"), + )])); + + // Create a token with an untrusted issuer + let claims = serde_json::json!({ + "iss": "https://evil.com", + "sub": "user123", + "jti": "token123", + "exp": 9999999999i64, + }); + + let token = create_test_jwt(&claims, "test-kid", Algorithm::HS256); + + let result = validator + .preload_and_validate_token(&token, "access_token") + .await; + assert!( + matches!(result.unwrap_err(), TrustedIssuerError::UntrustedIssuer(_)), + "expected UntrustedIssuer error" + ); + } + + #[tokio::test] + async fn test_validate_token_missing_issuer_claim() { + let mut validator = TrustedIssuerValidator::new(HashMap::new()); + + // Create a token without issuer claim + let claims = serde_json::json!({ + "sub": "user123", + "jti": "token123", + }); + + let token = create_test_jwt(&claims, "test-kid", Algorithm::HS256); + + let result = validator + .preload_and_validate_token(&token, "access_token") + .await; + assert!( + matches!(result.unwrap_err(), TrustedIssuerError::MissingIssuerClaim), + "expected MissingIssuerClaim error" + ); + } + + #[tokio::test] + async fn test_validate_token_untrusted_token_type() { + let mut metadata = HashMap::new(); + metadata.insert( + "access_token".to_string(), + TokenEntityMetadata::builder() + .entity_type_name("Jans::Access_token".to_string()) + .trusted(false) // Not trusted! + .token_id("jti".to_string()) + .build(), + ); + + let issuer = create_test_issuer_with_metadata( + "test", + "https://test.com/.well-known/openid-configuration", + metadata, + ); + + let mut validator = + TrustedIssuerValidator::new(HashMap::from([("test".to_string(), issuer)])); + + let claims = serde_json::json!({ + "iss": "test", + "sub": "user123", + "jti": "token123", + }); + + let token = create_test_jwt(&claims, "test-kid", Algorithm::HS256); + + let result = validator + .preload_and_validate_token(&token, "access_token") + .await; + assert!( + matches!(result.unwrap_err(), TrustedIssuerError::UntrustedIssuer(_)), + "expected UntrustedIssuer error" + ); + } + + #[tokio::test] + async fn test_validate_token_token_type_not_configured() { + let issuer = + create_test_issuer("test", "https://test.com/.well-known/openid-configuration"); + + let mut validator = + TrustedIssuerValidator::new(HashMap::from([("test".to_string(), issuer)])); + + let claims = serde_json::json!({ + "iss": "test", + "sub": "user123", + "jti": "token123", + }); + + let token = create_test_jwt(&claims, "test-kid", Algorithm::HS256); + + // Request validation for a token type that's not configured + let result = validator + .preload_and_validate_token(&token, "userinfo_token") + .await; + assert!( + matches!( + result.unwrap_err(), + TrustedIssuerError::TokenTypeNotConfigured { .. } + ), + "expected TokenTypeNotConfigured error" + ); + } + + #[tokio::test] + async fn test_validate_token_missing_required_claims_integration() { + let mut server = mockito::Server::new_async().await; + let oidc_url = format!("{}/.well-known/openid-configuration", server.url()); + + // Mock OIDC configuration + let _oidc_mock = server + .mock("GET", "/.well-known/openid-configuration") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + serde_json::json!({ + "issuer": server.url(), + "jwks_uri": format!("{}/jwks", server.url()), + }) + .to_string(), + ) + .create_async() + .await; + + // Mock JWKS endpoint with the test secret key (HS256) + // For HS256, we need to provide the key in JWKS format + let _jwks_mock = server + .mock("GET", "/jwks") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + serde_json::json!({ + "keys": [{ + "kty": "oct", + "kid": "test-kid", + "use": "sig", + "alg": "HS256", + "k": BASE64_URL_SAFE_NO_PAD.encode(b"test_secret_key") + }] + }) + .to_string(), + ) + .create_async() + .await; + + let mut metadata = HashMap::new(); + metadata.insert( + "access_token".to_string(), + TokenEntityMetadata::builder() + .entity_type_name("Jans::Access_token".to_string()) + .user_id(Some("sub".to_string())) + .role_mapping(Some("role".to_string())) + .token_id("jti".to_string()) + // Explicitly require "role" claim + .required_claims(HashSet::from(["role".to_string()])) + .build(), + ); + + let issuer = create_test_issuer_with_metadata("test", &oidc_url, metadata); + + let mut validator = + TrustedIssuerValidator::new(HashMap::from([("test".to_string(), issuer)])); + + // Token missing "role" claim which is in required_claims + let claims = serde_json::json!({ + "iss": server.url(), + "sub": "user123", + "jti": "token123", + // Missing "role" - which is required + }); + + let token = create_test_jwt(&claims, "test-kid", Algorithm::HS256); + + let result = validator + .preload_and_validate_token(&token, "access_token") + .await; + assert!( + matches!( + result.unwrap_err(), + TrustedIssuerError::MissingRequiredClaim { claim, .. } if claim == "role" + ), + "expected MissingRequiredClaim error for 'role'" + ); + } + + #[tokio::test] + async fn test_validator_with_logger() { + let issuers = HashMap::from([( + "issuer1".to_string(), + create_test_issuer("1", "https://issuer1.com/.well-known/openid-configuration"), + )]); + + // Test with None logger (valid case) + let validator_none = TrustedIssuerValidator::with_logger(issuers.clone(), None); + assert!(validator_none.logger.is_none()); + + // Test with Some logger - we'll test that the constructor accepts it + // Note: Creating a real Logger requires internal log types, so we just test None case + // The important part is that the API supports Option + + // Verify trusted issuers are loaded + let result = validator_none.find_trusted_issuer("issuer1"); + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_multiple_issuers_matching() { + let issuers = HashMap::from([ + ( + "issuer1".to_string(), + create_test_issuer("1", "https://issuer1.com/.well-known/openid-configuration"), + ), + ( + "issuer2".to_string(), + create_test_issuer("2", "https://issuer2.com/.well-known/openid-configuration"), + ), + ( + "issuer3".to_string(), + create_test_issuer("3", "https://issuer3.com/.well-known/openid-configuration"), + ), + ]); + + let validator = TrustedIssuerValidator::new(issuers); + + // Test matching each issuer + assert!(validator.find_trusted_issuer("issuer1").is_ok()); + assert!(validator.find_trusted_issuer("issuer2").is_ok()); + assert!(validator.find_trusted_issuer("issuer3").is_ok()); + + // Test URL-based matching + assert!(validator.find_trusted_issuer("https://issuer1.com").is_ok()); + assert!(validator.find_trusted_issuer("https://issuer2.com").is_ok()); + + // Test invalid issuer + assert!(validator.find_trusted_issuer("issuer4").is_err()); + assert!(validator.find_trusted_issuer("https://evil.com").is_err()); + } +} diff --git a/jans-cedarling/cedarling/src/jwt/validation/validator.rs b/jans-cedarling/cedarling/src/jwt/validation/validator.rs index 77075dd11f0..6002570795c 100644 --- a/jans-cedarling/cedarling/src/jwt/validation/validator.rs +++ b/jans-cedarling/cedarling/src/jwt/validation/validator.rs @@ -8,6 +8,7 @@ use std::collections::HashSet; use crate::common::policy_store::{TokenEntityMetadata, TrustedIssuer}; use crate::jwt::decode::*; use crate::jwt::key_service::DecodingKeyInfo; +use crate::jwt::validation::TrustedIssuerError; use crate::jwt::*; use jsonwebtoken::{self as jwt, Algorithm, DecodingKey, Validation}; use serde::{Deserialize, Serialize}; @@ -318,6 +319,8 @@ pub enum ValidateJwtError { MissingStatusList, #[error("failed to deserialize the JWT's status claim: {0}")] DeserializeStatusClaim(#[from] serde_json::Error), + #[error("failed to validate the JWT's trusted issuer: {0}")] + TrustedIssuerValidation(#[source] TrustedIssuerError), } #[cfg(test)] diff --git a/jans-cedarling/cedarling/src/lib.rs b/jans-cedarling/cedarling/src/lib.rs index 7e98a2f3dbf..e5bfc92d7c9 100644 --- a/jans-cedarling/cedarling/src/lib.rs +++ b/jans-cedarling/cedarling/src/lib.rs @@ -52,6 +52,10 @@ use log::LogEntry; use log::interface::LogWriter; pub use log::{LogLevel, LogStorage}; +// JWT validation exports +pub use jwt::{JwtService, TrustedIssuerError, TrustedIssuerValidator, validate_required_claims}; +use semver::Version; + #[doc(hidden)] pub mod bindings { pub use cedar_policy; @@ -145,6 +149,11 @@ impl Cedarling { let mut service_factory = ServiceFactory::new(config, service_config, log.clone()); + // Log policy store metadata if available (new format only) + if let Some(metadata) = service_factory.policy_store_metadata() { + log_policy_store_metadata(&log, metadata); + } + Ok(Cedarling { log, authz: service_factory.authz_service().await?, @@ -192,6 +201,107 @@ impl Cedarling { } } +/// Log detailed information about the loaded policy store metadata, including +/// ID, version, description, Cedar version, timestamps, and compatibility with +/// the runtime Cedar version. +fn log_policy_store_metadata( + log: &log::Logger, + metadata: &crate::common::policy_store::PolicyStoreMetadata, +) { + // Build detailed log message using accessor methods + let mut details = format!( + "Policy store '{}' (ID: {}) v{} loaded", + metadata.name(), + if metadata.id().is_empty() { + "" + } else { + metadata.id() + }, + metadata.version() + ); + + // Add description if available + if let Some(desc) = metadata.description() { + details.push_str(&format!(" - {}", desc)); + } + + // Add Cedar version info + details.push_str(&format!(" [Cedar {}]", metadata.cedar_version())); + + // Add timestamp info if available + if let Some(created) = metadata.created_date() { + details.push_str(&format!(" (created: {})", created.format("%Y-%m-%d"))); + } + if let Some(updated) = metadata.updated_date() { + details.push_str(&format!(" (updated: {})", updated.format("%Y-%m-%d"))); + } + + log.log_any( + LogEntry::new(BaseLogEntry::new_system_opt_request_id( + LogLevel::DEBUG, + None, + )) + .set_message(details), + ); + + // Log version compatibility check with current Cedar + let current_cedar_version: Version = cedar_policy::get_lang_version(); + match metadata.is_compatible_with_cedar(¤t_cedar_version) { + Ok(true) => { + log.log_any( + LogEntry::new(BaseLogEntry::new_system_opt_request_id( + LogLevel::DEBUG, + None, + )) + .set_message(format!( + "Policy store Cedar version {} is compatible with runtime version {}", + metadata.cedar_version(), + current_cedar_version + )), + ); + }, + Ok(false) => { + log.log_any( + LogEntry::new(BaseLogEntry::new_system_opt_request_id( + LogLevel::WARN, + None, + )) + .set_message(format!( + "Policy store Cedar version {} may not be compatible with runtime version {}", + metadata.cedar_version(), + current_cedar_version + )), + ); + }, + Err(e) => { + log.log_any( + LogEntry::new(BaseLogEntry::new_system_opt_request_id( + LogLevel::WARN, + None, + )) + .set_message(format!( + "Could not check Cedar version compatibility: {}", + e + )), + ); + }, + } + + // Log parsed version for debugging if available + if let Some(parsed_version) = metadata.version_parsed() { + log.log_any( + LogEntry::new(BaseLogEntry::new_system_opt_request_id( + LogLevel::TRACE, + None, + )) + .set_message(format!( + "Policy store semantic version: {}.{}.{}", + parsed_version.major, parsed_version.minor, parsed_version.patch + )), + ); + } +} + // implements LogStorage for Cedarling // we can use this methods outside crate only when import trait impl LogStorage for Cedarling { diff --git a/jans-cedarling/cedarling/src/lock/mod.rs b/jans-cedarling/cedarling/src/lock/mod.rs index e3a473cc0a5..d06fa1055d8 100644 --- a/jans-cedarling/cedarling/src/lock/mod.rs +++ b/jans-cedarling/cedarling/src/lock/mod.rs @@ -120,7 +120,7 @@ struct WorkerSenderAndHandle { /// Stores logs in a buffer then sends them to the lock server in the background #[derive(Debug)] -pub(crate) struct LockService { +pub struct LockService { log_worker: Option, logger: Option, cancel_tkn: CancellationToken, diff --git a/jans-cedarling/cedarling/src/log/log_strategy.rs b/jans-cedarling/cedarling/src/log/log_strategy.rs index 35bbe8b809a..91cf18de288 100644 --- a/jans-cedarling/cedarling/src/log/log_strategy.rs +++ b/jans-cedarling/cedarling/src/log/log_strategy.rs @@ -18,7 +18,7 @@ use crate::log::BaseLogEntry; use crate::log::loggable_fn::LoggableFn; use serde::Serialize; -pub(crate) struct LogStrategy { +pub struct LogStrategy { logger: LogStrategyLogger, pdp_id: PdpID, app_name: Option, @@ -37,7 +37,7 @@ pub(crate) enum LogStrategyLogger { impl LogStrategy { /// Creates a new `LogStrategy` based on the provided configuration. /// Initializes the corresponding logger accordingly. - pub fn new( + pub(crate) fn new( config: &LogConfig, pdp_id: PdpID, app_name: Option, diff --git a/jans-cedarling/cedarling/src/log/mod.rs b/jans-cedarling/cedarling/src/log/mod.rs index 769c653bbfc..3471569b89c 100644 --- a/jans-cedarling/cedarling/src/log/mod.rs +++ b/jans-cedarling/cedarling/src/log/mod.rs @@ -89,7 +89,7 @@ pub(crate) type LoggerWeak = Weak; #[allow(dead_code)] #[cfg(test)] -pub(crate) static TEST_LOGGER: LazyLock = LazyLock::new(|| init_test_logger()); +pub(crate) static TEST_LOGGER: LazyLock = LazyLock::new(init_test_logger); /// Initialize logger. /// entry point for initialize logger diff --git a/jans-cedarling/cedarling/src/tests/mod.rs b/jans-cedarling/cedarling/src/tests/mod.rs index 92ddacc71e4..7a01f862252 100644 --- a/jans-cedarling/cedarling/src/tests/mod.rs +++ b/jans-cedarling/cedarling/src/tests/mod.rs @@ -7,13 +7,14 @@ mod utils; +mod authorize_multi_issuer; mod authorize_resource_entity; mod authorize_unsigned; mod cases_authorize_different_principals; mod cases_authorize_namespace_jans2; mod cases_authorize_without_check_jwt; mod json_logic; -mod authorize_multi_issuer; +mod policy_store_loader; mod schema_type_mapping; mod ssa_validation_integration; mod success_test_json; diff --git a/jans-cedarling/cedarling/src/tests/policy_store_loader.rs b/jans-cedarling/cedarling/src/tests/policy_store_loader.rs new file mode 100644 index 00000000000..e8c42330a26 --- /dev/null +++ b/jans-cedarling/cedarling/src/tests/policy_store_loader.rs @@ -0,0 +1,1244 @@ +// This software is available under the Apache-2.0 license. +// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text. +// +// Copyright (c) 2024, Gluu, Inc. + +//! Integration tests for the new policy store loader. +//! +//! These tests verify that: +//! - Directory-based policy stores load correctly and can be used for authorization +//! - Cedar Archive (.cjar) files load correctly and can be used for authorization +//! - Manifest validation works as expected (checksums, policy store ID matching) +//! - Error cases are handled properly at the API level +//! +//! The tests use the same `Cedarling` API and patterns as other integration tests, +//! ensuring the new loader paths work end-to-end. +//! +//! ## Platform Support +//! +//! - **Native platforms**: All tests run, including directory/file-based loading +//! - **WASM**: Tests using `CjarUrl` and `load_policy_store_archive_bytes` work, +//! as they don't require filesystem access. Directory and file-based tests are +//! skipped with `#[cfg(not(target_arch = "wasm32"))]`. + +#[cfg(not(target_arch = "wasm32"))] +use std::fs; +#[cfg(not(target_arch = "wasm32"))] +use std::io::Read; + +use serde_json::json; +#[cfg(not(target_arch = "wasm32"))] +use tempfile::TempDir; +use tokio::test; +#[cfg(not(target_arch = "wasm32"))] +use zip::read::ZipArchive; + +use super::utils::*; +use crate::authz::request::EntityData; +use crate::common::policy_store::test_utils::PolicyStoreTestBuilder; +use crate::tests::utils::cedarling_util::get_cedarling_with_callback; +use crate::{Cedarling, PolicyStoreSource, RequestUnsigned}; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/// Creates a policy store builder configured for authorization testing. +/// +/// This builder includes: +/// - A schema with User, Resource, and Action types +/// - A simple "allow-read" policy +/// - A "deny-write-guest" policy based on user_type attribute +fn create_authz_policy_store_builder() -> PolicyStoreTestBuilder { + PolicyStoreTestBuilder::new("a1b2c3d4e5f6a7b8") + .with_name("Integration Test Policy Store") + .with_schema( + r#"namespace TestApp { + entity User { + name: String, + user_type: String, + }; + entity Resource { + name: String, + }; + + action "read" appliesTo { + principal: [User], + resource: [Resource] + }; + + action "write" appliesTo { + principal: [User], + resource: [Resource] + }; +} +"#, + ) + .with_policy( + "allow-read", + r#"@id("allow-read") +permit( + principal, + action == TestApp::Action::"read", + resource +);"#, + ) + .with_policy( + "deny-write-guest", + r#"@id("deny-write-guest") +forbid( + principal, + action == TestApp::Action::"write", + resource +) when { principal.user_type == "guest" };"#, + ) +} + +/// Extracts a zip archive to a temporary directory. +#[cfg(not(target_arch = "wasm32"))] +fn extract_archive_to_temp_dir(archive_bytes: &[u8]) -> TempDir { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let mut zip_archive = + ZipArchive::new(std::io::Cursor::new(archive_bytes)).expect("Failed to read zip archive"); + + for i in 0..zip_archive.len() { + let mut file = zip_archive.by_index(i).expect("Failed to get zip entry"); + let file_path = temp_dir.path().join(file.name()); + + if file.is_dir() { + fs::create_dir_all(&file_path).expect("Failed to create directory"); + } else { + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).expect("Failed to create parent directory"); + } + let mut contents = Vec::new(); + file.read_to_end(&mut contents) + .expect("Failed to read file contents"); + fs::write(&file_path, contents).expect("Failed to write file"); + } + } + + temp_dir +} + +/// Creates a Cedarling instance from a directory path. +/// +/// Disables default entity building (user, workload, roles) since we're using +/// a custom schema that doesn't include the Jans namespace types. +/// Uses a custom principal_bool_operator that checks for TestApp::User principal. +async fn get_cedarling_from_directory(path: std::path::PathBuf) -> Cedarling { + use crate::JsonRule; + + get_cedarling_with_callback(PolicyStoreSource::Directory(path), |config| { + // Disable default entity builders that expect Jans namespace types + config.entity_builder_config.build_user = false; + config.entity_builder_config.build_workload = false; + config.authorization_config.use_user_principal = false; + config.authorization_config.use_workload_principal = false; + + // Use a custom operator that checks for our TestApp::User principal + config.authorization_config.principal_bool_operator = JsonRule::new(json!({ + "===": [{"var": "TestApp::User"}, "ALLOW"] + })) + .expect("Failed to create principal bool operator"); + }) + .await +} + +/// Creates a Cedarling instance from an archive file path. +/// +/// Disables default entity building (user, workload, roles) since we're using +/// a custom schema that doesn't include the Jans namespace types. +/// Uses a custom principal_bool_operator that checks for TestApp::User principal. +async fn get_cedarling_from_cjar_file(path: std::path::PathBuf) -> Cedarling { + use crate::JsonRule; + + get_cedarling_with_callback(PolicyStoreSource::CjarFile(path), |config| { + // Disable default entity builders that expect Jans namespace types + config.entity_builder_config.build_user = false; + config.entity_builder_config.build_workload = false; + config.authorization_config.use_user_principal = false; + config.authorization_config.use_workload_principal = false; + + // Use a custom operator that checks for our TestApp::User principal + config.authorization_config.principal_bool_operator = JsonRule::new(json!({ + "===": [{"var": "TestApp::User"}, "ALLOW"] + })) + .expect("Failed to create principal bool operator"); + }) + .await +} + +// ============================================================================ +// Directory-Based Loading Tests +// ============================================================================ + +/// Test that a policy store loaded from a directory works for authorization. +#[test] +#[cfg(not(target_arch = "wasm32"))] +async fn test_load_from_directory_and_authorize_success() { + // Build archive and extract to temp directory + let builder = create_authz_policy_store_builder(); + let archive = builder + .build_archive() + .expect("Failed to build test archive"); + let temp_dir = extract_archive_to_temp_dir(&archive); + + // Create Cedarling from directory + let cedarling = get_cedarling_from_directory(temp_dir.path().to_path_buf()).await; + + // Create an authorization request + let request = RequestUnsigned { + action: "TestApp::Action::\"read\"".to_string(), + context: json!({}), + principals: vec![ + EntityData::deserialize(json!({ + "cedar_entity_mapping": { + "entity_type": "TestApp::User", + "id": "user1" + }, + "name": "Test User", + "user_type": "admin" + })) + .expect("Failed to create principal"), + ], + resource: EntityData::deserialize(json!({ + "cedar_entity_mapping": { + "entity_type": "TestApp::Resource", + "id": "resource1" + }, + "name": "Test Resource" + })) + .expect("Failed to create resource"), + }; + + // Execute authorization + let result = cedarling + .authorize_unsigned(request) + .await + .expect("Authorization should succeed"); + + // Verify the result - read action should be allowed + assert!( + result.decision, + "Read action should be allowed by the allow-read policy" + ); +} + +/// Test that write action is denied for guest users when loaded from directory. +#[test] +#[cfg(not(target_arch = "wasm32"))] +async fn test_load_from_directory_deny_write_for_guest() { + // Build archive and extract to temp directory + let builder = create_authz_policy_store_builder(); + let archive = builder + .build_archive() + .expect("Failed to build test archive"); + let temp_dir = extract_archive_to_temp_dir(&archive); + + // Create Cedarling from directory + let cedarling = get_cedarling_from_directory(temp_dir.path().to_path_buf()).await; + + // Create an authorization request for write action with guest user_type + let request = RequestUnsigned { + action: "TestApp::Action::\"write\"".to_string(), + context: json!({}), + principals: vec![ + EntityData::deserialize(json!({ + "cedar_entity_mapping": { + "entity_type": "TestApp::User", + "id": "guest_user" + }, + "name": "Guest User", + "user_type": "guest" + })) + .expect("Failed to create principal"), + ], + resource: EntityData::deserialize(json!({ + "cedar_entity_mapping": { + "entity_type": "TestApp::Resource", + "id": "resource1" + }, + "name": "Test Resource" + })) + .expect("Failed to create resource"), + }; + + // Execute authorization + let result = cedarling + .authorize_unsigned(request) + .await + .expect("Authorization should succeed"); + + // Verify the result - write action should be denied for guest + assert!( + !result.decision, + "Write action should be denied for guest users by the deny-write-guest policy" + ); +} + +// ============================================================================ +// Archive (.cjar) Loading Tests +// ============================================================================ + +/// Test that a policy store loaded from a .cjar file works for authorization. +#[test] +#[cfg(not(target_arch = "wasm32"))] +async fn test_load_from_cjar_file_and_authorize_success() { + // Build archive + let builder = create_authz_policy_store_builder(); + let archive = builder + .build_archive() + .expect("Failed to build test archive"); + + // Write archive to temp file + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let archive_path = temp_dir.path().join("test_policy_store.cjar"); + fs::write(&archive_path, &archive).expect("Failed to write archive file"); + + // Create Cedarling from archive file + let cedarling = get_cedarling_from_cjar_file(archive_path).await; + + // Create an authorization request + let request = RequestUnsigned { + action: "TestApp::Action::\"read\"".to_string(), + context: json!({}), + principals: vec![ + EntityData::deserialize(json!({ + "cedar_entity_mapping": { + "entity_type": "TestApp::User", + "id": "user1" + }, + "name": "Test User", + "user_type": "admin" + })) + .expect("Failed to create principal"), + ], + resource: EntityData::deserialize(json!({ + "cedar_entity_mapping": { + "entity_type": "TestApp::Resource", + "id": "resource1" + }, + "name": "Test Resource" + })) + .expect("Failed to create resource"), + }; + + // Execute authorization + let result = cedarling + .authorize_unsigned(request) + .await + .expect("Authorization should succeed"); + + // Verify the result + assert!( + result.decision, + "Read action should be allowed by the allow-read policy" + ); +} + +// ============================================================================ +// Manifest Validation Tests +// ============================================================================ + +/// Test that manifest validation detects checksum mismatches. +/// +/// This test uses `load_policy_store_directory` which performs manifest validation. +/// An invalid checksum format in the manifest should cause initialization to fail. +#[test] +#[cfg(not(target_arch = "wasm32"))] +async fn test_manifest_validation_invalid_checksum_format() { + use super::utils::cedarling_util::get_config; + use crate::common::policy_store::test_utils::fixtures; + + let mut builder = fixtures::minimal_valid(); + + // Add manifest with invalid checksum format (missing sha256: prefix) + builder.extra_files.insert( + "manifest.json".to_string(), + r#"{ + "policy_store_id": "abc123def456", + "generated_date": "2024-01-01T00:00:00Z", + "files": { + "metadata.json": { + "size": 100, + "checksum": "invalid_format_no_sha256_prefix" + } + } + }"# + .to_string(), + ); + + let archive = builder + .build_archive() + .expect("Failed to build test archive"); + let temp_dir = extract_archive_to_temp_dir(&archive); + + // Attempt to create Cedarling - should fail due to invalid checksum format + let config = get_config(PolicyStoreSource::Directory(temp_dir.path().to_path_buf())); + + let err = Cedarling::new(&config) + .await + .err() + .expect("Cedarling initialization should fail with invalid checksum format"); + + // Verify the error is a Directory error containing the checksum format message + assert!( + matches!( + &err, + crate::InitCedarlingError::ServiceConfig( + crate::init::service_config::ServiceConfigError::PolicyStore( + crate::init::policy_store::PolicyStoreLoadError::Directory(msg) + ) + ) if msg.contains("Invalid checksum format") + ), + "Expected Directory error with 'Invalid checksum format', got: {:?}", + err + ); +} + +/// Test that manifest validation detects policy store ID mismatches. +#[test] +#[cfg(not(target_arch = "wasm32"))] +async fn test_manifest_validation_policy_store_id_mismatch() { + use super::utils::cedarling_util::get_config; + use crate::common::policy_store::test_utils::fixtures; + + let mut builder = fixtures::minimal_valid(); + + // Add manifest with wrong policy_store_id (metadata has "abc123def456") + builder.extra_files.insert( + "manifest.json".to_string(), + r#"{ + "policy_store_id": "wrong_id_12345", + "generated_date": "2024-01-01T00:00:00Z", + "files": {} + }"# + .to_string(), + ); + + let archive = builder + .build_archive() + .expect("Failed to build test archive"); + let temp_dir = extract_archive_to_temp_dir(&archive); + + // Attempt to create Cedarling - should fail due to ID mismatch + let config = get_config(PolicyStoreSource::Directory(temp_dir.path().to_path_buf())); + + let err = Cedarling::new(&config) + .await + .err() + .expect("Cedarling initialization should fail with policy store ID mismatch"); + + // Verify the error is a Directory error containing the ID mismatch message + assert!( + matches!( + &err, + crate::InitCedarlingError::ServiceConfig( + crate::init::service_config::ServiceConfigError::PolicyStore( + crate::init::policy_store::PolicyStoreLoadError::Directory(msg) + ) + ) if msg.contains("Policy store ID mismatch") + ), + "Expected Directory error with 'Policy store ID mismatch', got: {:?}", + err + ); +} + +// ============================================================================ +// Policy Store with Entities Tests +// ============================================================================ + +/// Test loading a policy store with pre-defined entities. +#[test] +#[cfg(not(target_arch = "wasm32"))] +async fn test_load_directory_with_entities() { + // Build a policy store with entities + let builder = PolicyStoreTestBuilder::new("e1e2e3e4e5e6e7e8") + .with_name("Entity Test Policy Store") + .with_schema( + r#"namespace TestApp { + entity User { + name: String, + department: String, + }; + entity Resource { + name: String, + owner: String, + }; + + action "access" appliesTo { + principal: [User], + resource: [Resource] + }; +} +"#, + ) + .with_policy( + "allow-same-department", + r#"@id("allow-same-department") +permit( + principal, + action == TestApp::Action::"access", + resource +);"#, + ) + .with_entity( + "users", + serde_json::to_string(&json!([ + { + "uid": {"type": "TestApp::User", "id": "alice"}, + "attrs": { + "name": "Alice", + "department": "engineering" + }, + "parents": [] + } + ])) + .unwrap(), + ) + .with_entity( + "resources", + serde_json::to_string(&json!([ + { + "uid": {"type": "TestApp::Resource", "id": "doc1"}, + "attrs": { + "name": "Design Document", + "owner": "engineering" + }, + "parents": [] + } + ])) + .unwrap(), + ); + + let archive = builder + .build_archive() + .expect("Failed to build test archive"); + let temp_dir = extract_archive_to_temp_dir(&archive); + + // Create Cedarling from directory + let cedarling = get_cedarling_from_directory(temp_dir.path().to_path_buf()).await; + + // Create an authorization request + let request = RequestUnsigned { + action: "TestApp::Action::\"access\"".to_string(), + context: json!({}), + principals: vec![ + EntityData::deserialize(json!({ + "cedar_entity_mapping": { + "entity_type": "TestApp::User", + "id": "alice" + }, + "name": "Alice", + "department": "engineering" + })) + .expect("Failed to create principal"), + ], + resource: EntityData::deserialize(json!({ + "cedar_entity_mapping": { + "entity_type": "TestApp::Resource", + "id": "doc1" + }, + "name": "Design Document", + "owner": "engineering" + })) + .expect("Failed to create resource"), + }; + + // Execute authorization + let result = cedarling + .authorize_unsigned(request) + .await + .expect("Authorization should succeed"); + + // Verify the result + assert!( + result.decision, + "Access should be allowed by the allow-same-department policy" + ); +} + +// ============================================================================ +// Multiple Policies Tests +// ============================================================================ + +/// Test loading a policy store with multiple policies and verifying correct policy evaluation. +#[test] +#[cfg(not(target_arch = "wasm32"))] +async fn test_load_directory_with_multiple_policies() { + // Build a policy store with multiple policies + let builder = PolicyStoreTestBuilder::new("f1f2f3f4f5f6f7f8") + .with_name("Multi-Policy Test Store") + .with_schema( + r#"namespace TestApp { + entity User { + user_role: String, + }; + entity Resource; + + action "read" appliesTo { + principal: [User], + resource: [Resource] + }; + + action "write" appliesTo { + principal: [User], + resource: [Resource] + }; + + action "delete" appliesTo { + principal: [User], + resource: [Resource] + }; +} +"#, + ) + .with_policy( + "allow-read-all", + r#"@id("allow-read-all") +permit( + principal, + action == TestApp::Action::"read", + resource +);"#, + ) + .with_policy( + "allow-write-admin", + r#"@id("allow-write-admin") +permit( + principal, + action == TestApp::Action::"write", + resource +) when { principal.user_role == "admin" };"#, + ) + .with_policy( + "deny-delete-all", + r#"@id("deny-delete-all") +forbid( + principal, + action == TestApp::Action::"delete", + resource +);"#, + ); + + let archive = builder + .build_archive() + .expect("Failed to build test archive"); + let temp_dir = extract_archive_to_temp_dir(&archive); + + // Create Cedarling from directory + let cedarling = get_cedarling_from_directory(temp_dir.path().to_path_buf()).await; + + // Test 1: Read should be allowed for any user + let read_request = RequestUnsigned { + action: "TestApp::Action::\"read\"".to_string(), + context: json!({}), + principals: vec![ + EntityData::deserialize(json!({ + "cedar_entity_mapping": { + "entity_type": "TestApp::User", + "id": "user1" + }, + "user_role": "viewer" + })) + .expect("Failed to create principal"), + ], + resource: EntityData::deserialize(json!({ + "cedar_entity_mapping": { + "entity_type": "TestApp::Resource", + "id": "resource1" + } + })) + .expect("Failed to create resource"), + }; + + let read_result = cedarling + .authorize_unsigned(read_request) + .await + .expect("Read authorization should succeed"); + + assert!(read_result.decision, "Read should be allowed for any user"); + + // Test 2: Write should be allowed only for admin + let write_admin_request = RequestUnsigned { + action: "TestApp::Action::\"write\"".to_string(), + context: json!({}), + principals: vec![ + EntityData::deserialize(json!({ + "cedar_entity_mapping": { + "entity_type": "TestApp::User", + "id": "admin1" + }, + "user_role": "admin" + })) + .expect("Failed to create principal"), + ], + resource: EntityData::deserialize(json!({ + "cedar_entity_mapping": { + "entity_type": "TestApp::Resource", + "id": "resource1" + } + })) + .expect("Failed to create resource"), + }; + + let write_admin_result = cedarling + .authorize_unsigned(write_admin_request) + .await + .expect("Write authorization should succeed"); + + assert!( + write_admin_result.decision, + "Write should be allowed for admin" + ); + + // Test 3: Write should be denied for non-admin + let write_viewer_request = RequestUnsigned { + action: "TestApp::Action::\"write\"".to_string(), + context: json!({}), + principals: vec![ + EntityData::deserialize(json!({ + "cedar_entity_mapping": { + "entity_type": "TestApp::User", + "id": "user1" + }, + "user_role": "viewer" + })) + .expect("Failed to create principal"), + ], + resource: EntityData::deserialize(json!({ + "cedar_entity_mapping": { + "entity_type": "TestApp::Resource", + "id": "resource1" + } + })) + .expect("Failed to create resource"), + }; + + let write_viewer_result = cedarling + .authorize_unsigned(write_viewer_request) + .await + .expect("Write authorization should succeed"); + + assert!( + !write_viewer_result.decision, + "Write should be denied for non-admin" + ); + + // Test 4: Delete should be denied for everyone + let delete_request = RequestUnsigned { + action: "TestApp::Action::\"delete\"".to_string(), + context: json!({}), + principals: vec![ + EntityData::deserialize(json!({ + "cedar_entity_mapping": { + "entity_type": "TestApp::User", + "id": "admin1" + }, + "user_role": "admin" + })) + .expect("Failed to create principal"), + ], + resource: EntityData::deserialize(json!({ + "cedar_entity_mapping": { + "entity_type": "TestApp::Resource", + "id": "resource1" + } + })) + .expect("Failed to create resource"), + }; + + let delete_result = cedarling + .authorize_unsigned(delete_request) + .await + .expect("Delete authorization should succeed"); + + assert!( + !delete_result.decision, + "Delete should be denied for everyone" + ); +} + +// ============================================================================ +// Archive URL Tests (WASM-Compatible via CjarUrl) +// ============================================================================ + +/// Test loading a policy store from a URL using mockito. +/// +/// This test is WASM-compatible as it uses HTTP to fetch the archive, +/// which works in both native and WASM environments. +#[test] +async fn test_load_from_cjar_url_and_authorize_success() { + use crate::JsonRule; + use mockito::Server; + + // Build archive bytes + let builder = create_authz_policy_store_builder(); + let archive_bytes = builder + .build_archive() + .expect("Failed to build test archive"); + + // Create mock server + let mut server = Server::new_async().await; + let mock = server + .mock("GET", "/policy-store.cjar") + .with_status(200) + .with_header("content-type", "application/octet-stream") + .with_body(archive_bytes) + .create_async() + .await; + + let cjar_url = format!("{}/policy-store.cjar", server.url()); + + // Create Cedarling from CjarUrl + let cedarling = get_cedarling_with_callback(PolicyStoreSource::CjarUrl(cjar_url), |config| { + // Disable default entity builders that expect Jans namespace types + config.entity_builder_config.build_user = false; + config.entity_builder_config.build_workload = false; + config.authorization_config.use_user_principal = false; + config.authorization_config.use_workload_principal = false; + + // Use a custom operator that checks for our TestApp::User principal + config.authorization_config.principal_bool_operator = JsonRule::new(json!({ + "===": [{"var": "TestApp::User"}, "ALLOW"] + })) + .expect("Failed to create principal bool operator"); + }) + .await; + + // Verify the mock was called + mock.assert_async().await; + + // Create an authorization request + let request = RequestUnsigned { + action: "TestApp::Action::\"read\"".to_string(), + context: json!({}), + principals: vec![ + EntityData::deserialize(json!({ + "cedar_entity_mapping": { + "entity_type": "TestApp::User", + "id": "user1" + }, + "name": "Test User", + "user_type": "admin" + })) + .expect("Failed to create principal"), + ], + resource: EntityData::deserialize(json!({ + "cedar_entity_mapping": { + "entity_type": "TestApp::Resource", + "id": "resource1" + }, + "name": "Test Resource" + })) + .expect("Failed to create resource"), + }; + + // Execute authorization + let result = cedarling + .authorize_unsigned(request) + .await + .expect("Authorization should succeed"); + + // Verify the result + assert!( + result.decision, + "Read action should be allowed when loading from CjarUrl" + ); +} + +/// Test that CjarUrl handles HTTP errors gracefully. +/// The HTTP client retries on HTTP error status codes before failing. +#[test] +async fn test_cjar_url_handles_http_error() { + use super::utils::cedarling_util::get_config; + use mockito::Server; + + // Create mock server that returns 404 + // Note: The HTTP client will retry on HTTP errors, so expect multiple requests + let mut server = Server::new_async().await; + let mock = server + .mock("GET", "/nonexistent.cjar") + .with_status(404) + .with_body("Not Found") + .expect_at_least(1) + .create_async() + .await; + + let cjar_url = format!("{}/nonexistent.cjar", server.url()); + + // Attempt to create Cedarling - should fail after retries + let config = get_config(PolicyStoreSource::CjarUrl(cjar_url)); + + let err = Cedarling::new(&config) + .await + .err() + .expect("Cedarling initialization should fail after retries on 404 error"); + + // Verify the mock was called at least once + mock.assert_async().await; + + // Verify the error is an Archive error (max retries exceeded after HTTP errors) + assert!( + matches!( + &err, + crate::InitCedarlingError::ServiceConfig( + crate::init::service_config::ServiceConfigError::PolicyStore( + crate::init::policy_store::PolicyStoreLoadError::Archive(_) + ) + ) + ), + "Expected Archive error after retries, got: {:?}", + err + ); +} + +/// Test loading archive from bytes directly using the loader function. +/// +/// This tests the `load_policy_store_archive_bytes` function which is the +/// underlying mechanism used by CjarUrl and is WASM-compatible. +#[test] +async fn test_load_policy_store_archive_bytes_directly() { + use crate::common::policy_store::loader::load_policy_store_archive_bytes; + + // Build archive bytes + let builder = create_authz_policy_store_builder(); + let archive_bytes = builder + .build_archive() + .expect("Failed to build test archive"); + + // Load directly using the bytes loader + let loaded = load_policy_store_archive_bytes(archive_bytes) + .expect("Should load policy store from bytes"); + + // Verify the loaded policy store + assert_eq!( + loaded.metadata.policy_store.id, "a1b2c3d4e5f6a7b8", + "Policy store ID should match" + ); + assert_eq!( + loaded.metadata.policy_store.name, "Integration Test Policy Store", + "Policy store name should match" + ); + assert!( + !loaded.policies.is_empty(), + "Should have loaded at least one policy" + ); + assert_eq!(loaded.policies.len(), 2, "Should have loaded 2 policies"); + + // Verify policy content + let policy_names: Vec<&str> = loaded.policies.iter().map(|p| p.name.as_str()).collect(); + assert!( + policy_names.contains(&"allow-read.cedar"), + "Should have allow-read policy" + ); + assert!( + policy_names.contains(&"deny-write-guest.cedar"), + "Should have deny-write-guest policy" + ); +} + +/// Test that invalid archive bytes are rejected. +#[test] +async fn test_load_policy_store_archive_bytes_invalid() { + use crate::common::policy_store::loader::load_policy_store_archive_bytes; + + // Try to load invalid bytes + let invalid_bytes = vec![0x00, 0x01, 0x02, 0x03]; + let err = load_policy_store_archive_bytes(invalid_bytes) + .expect_err("Should fail to load invalid archive bytes"); + + // Verify the error is an Archive error (invalid zip format) + assert!( + matches!( + err, + crate::common::policy_store::errors::PolicyStoreError::Archive(_) + ), + "Expected Archive error for invalid bytes, got: {:?}", + err + ); +} + +// ============================================================================ +// JWT Authorization Tests (using MockServer) +// ============================================================================ + +/// Test the `authorize` method with signed JWTs loaded from a directory-based policy store. +/// +/// This test verifies the full flow: +/// 1. Create a policy store with a trusted issuer pointing to MockServer +/// 2. MockServer provides OIDC config and JWKS endpoints +/// 3. Generate signed JWTs using MockServer +/// 4. Call `authorize` with the signed tokens +#[test] +#[cfg(not(target_arch = "wasm32"))] +async fn test_authorize_with_jwt_from_directory() { + use crate::authz::request::Request; + use crate::jwt::test_utils::MockServer; + use crate::log::StdOutLoggerMode; + use crate::{ + AuthorizationConfig, BootstrapConfig, EntityBuilderConfig, JsonRule, JwtConfig, LogConfig, + LogTypeConfig, PolicyStoreConfig, + }; + + // Create mock server for OIDC/JWKS + let mut mock_server = MockServer::new_with_defaults() + .await + .expect("Failed to create mock server"); + + let issuer_url = mock_server.issuer(); + let oidc_endpoint = format!("{}/.well-known/openid-configuration", issuer_url); + + // Create trusted issuer JSON that points to mock server + // Uses "Jans" as the issuer name to match the default entity builder namespace + let trusted_issuer_json = format!( + r#"{{ + "mock_issuer": {{ + "name": "Jans", + "description": "Test issuer for JWT validation", + "openid_configuration_endpoint": "{}", + "token_metadata": {{ + "access_token": {{ + "entity_type_name": "Jans::Access_token", + "workload_id": "client_id", + "principal_mapping": ["Jans::Workload"] + }}, + "id_token": {{ + "entity_type_name": "Jans::Id_token" + }}, + "userinfo_token": {{ + "entity_type_name": "Jans::Userinfo_token", + "user_id": "sub", + "role_mapping": "role" + }} + }} + }} +}}"#, + oidc_endpoint + ); + + // Schema that works with JWT-based authorization + // Uses Jans namespace to match the default entity builder + let schema = r#"namespace Jans { + type Url = {"host": String, "path": String, "protocol": String}; + entity TrustedIssuer = {"issuer_entity_id": Url}; + entity Access_token = { + aud: String, + exp: Long, + iat: Long, + iss: TrustedIssuer, + jti: String, + client_id?: String, + org_id?: String, + }; + entity Id_token = { + aud: Set, + exp: Long, + iat: Long, + iss: TrustedIssuer, + jti: String, + sub: String, + }; + entity Userinfo_token = { + country?: String, + exp?: Long, + iat?: Long, + iss: TrustedIssuer, + jti: String, + sub: String, + role?: Set, + }; + entity Workload { + iss: TrustedIssuer, + access_token: Access_token, + client_id: String, + org_id?: String, + }; + entity User { + userinfo_token: Userinfo_token, + country?: String, + role?: Set, + sub: String, + }; + entity Role; + entity Resource { + org_id?: String, + country?: String, + }; + action "Read" appliesTo { + principal: [Workload, User, Role], + resource: [Resource], + context: {} + }; +} +"#; + + // Build the policy store + let builder = PolicyStoreTestBuilder::new("a1b2c3d4e5f6a7b8") + .with_name("JWT Test Policy Store") + .with_schema(schema) + .with_policy( + "allow-workload-read", + r#"@id("allow-workload-read") +permit( + principal is Jans::Workload, + action == Jans::Action::"Read", + resource is Jans::Resource +)when{ + principal.access_token.org_id == resource.org_id +};"#, + ) + .with_trusted_issuer("mock_issuer", trusted_issuer_json); + + let archive = builder.build_archive().expect("Failed to build archive"); + let temp_dir = extract_archive_to_temp_dir(&archive); + + // Generate signed tokens using MockServer + let access_token = mock_server + .generate_token_with_hs256sig( + &mut json!({ + "org_id": "test_org", + "jti": "access_jti", + "client_id": "test_client", + "aud": "test_aud", + "exp": chrono::Utc::now().timestamp() + 3600, + "iat": chrono::Utc::now().timestamp(), + }), + None, + ) + .expect("Failed to generate access token"); + + let id_token = mock_server + .generate_token_with_hs256sig( + &mut json!({ + "jti": "id_jti", + "aud": ["test_aud"], + "sub": "test_user", + "exp": chrono::Utc::now().timestamp() + 3600, + "iat": chrono::Utc::now().timestamp(), + }), + None, + ) + .expect("Failed to generate id token"); + + let userinfo_token = mock_server + .generate_token_with_hs256sig( + &mut json!({ + "jti": "userinfo_jti", + "sub": "test_user", + "country": "US", + "role": ["Admin"], + "exp": chrono::Utc::now().timestamp() + 3600, + "iat": chrono::Utc::now().timestamp(), + }), + None, + ) + .expect("Failed to generate userinfo token"); + + // Configure Cedarling with JWT validation enabled + let config = BootstrapConfig { + application_name: "test_app".to_string(), + log_config: LogConfig { + log_type: LogTypeConfig::StdOut(StdOutLoggerMode::Immediate), + log_level: crate::LogLevel::DEBUG, + }, + policy_store_config: PolicyStoreConfig { + source: PolicyStoreSource::Directory(temp_dir.path().to_path_buf()), + }, + jwt_config: JwtConfig { + jwks: None, + jwt_sig_validation: true, + jwt_status_validation: false, + ..Default::default() + } + .allow_all_algorithms(), + authorization_config: AuthorizationConfig { + use_user_principal: false, + use_workload_principal: true, + decision_log_default_jwt_id: "jti".to_string(), + decision_log_user_claims: vec![], + decision_log_workload_claims: vec!["client_id".to_string()], + id_token_trust_mode: crate::IdTokenTrustMode::Never, + principal_bool_operator: JsonRule::new(json!({ + "===": [{"var": "Jans::Workload"}, "ALLOW"] + })) + .expect("Failed to create principal bool operator"), + }, + entity_builder_config: EntityBuilderConfig { + build_user: false, + build_workload: true, + ..Default::default() + }, + lock_config: None, + max_default_entities: None, + max_base64_size: None, + }; + + let cedarling = crate::Cedarling::new(&config) + .await + .expect("Cedarling should initialize with JWT-enabled config"); + + // Create authorization request with signed JWTs + let request = Request::deserialize(json!({ + "tokens": { + "access_token": access_token, + "id_token": id_token, + "userinfo_token": userinfo_token, + }, + "action": "Jans::Action::\"Read\"", + "resource": { + "cedar_entity_mapping": { + "entity_type": "Jans::Resource", + "id": "resource1" + }, + "org_id": "test_org", + "country": "US" + }, + "context": {}, + })) + .expect("Request should be deserialized"); + + // Execute authorization with valid signed tokens + let result = cedarling + .authorize(request) + .await + .expect("Authorization should succeed with valid JWTs"); + + assert!( + result.decision, + "Read action should be allowed for workload with matching org_id" + ); + + // Prove JWT validation is enforced: tampered token should fail + // Create a request with an invalid/tampered access token + let tampered_token = format!("{}.tampered", access_token); + let invalid_request = Request::deserialize(json!({ + "tokens": { + "access_token": tampered_token, + "id_token": id_token, + "userinfo_token": userinfo_token, + }, + "action": "Jans::Action::\"Read\"", + "resource": { + "cedar_entity_mapping": { + "entity_type": "Jans::Resource", + "id": "resource1" + }, + "org_id": "test_org", + "country": "US" + }, + "context": {}, + })) + .expect("Request should be deserialized"); + + let invalid_result = cedarling.authorize(invalid_request).await; + let err = invalid_result + .expect_err("Authorization should fail with tampered JWT when validation is enabled"); + // Tampered JWT should result in a JWT validation error + assert!( + matches!(&err, crate::authz::AuthorizeError::ProcessTokens(_)), + "Expected JWT processing error for tampered token, got: {:?}", + err + ); +} diff --git a/jans-cedarling/clippy.toml b/jans-cedarling/clippy.toml index abc5c1ecb32..ebd767a1da2 100644 --- a/jans-cedarling/clippy.toml +++ b/jans-cedarling/clippy.toml @@ -1,3 +1,18 @@ [[disallowed-methods]] path = "uuid7::uuid7" reason = "not allowed method in WASM, use cedarling::log::log_entry::gen_uuid7()" + +[[disallowed-methods]] +path = "std::time::SystemTime::now" +reason = "may not work correctly in WASM, use chrono::Utc::now() instead" + +## Temporarily allow std::eprintln/std::eprint. +## These are used during bootstrap before the logger is available (see JwtConfigRaw -> JwtConfig). +## TODO: Reinstate these disallowed-macros once bootstrap has a way to surface warnings via Logger. +# [[disallowed-macros]] +# path = "std::eprintln" +# reason = "bypasses logging infrastructure, doesn't work in WASM, use Logger instead" +# +# [[disallowed-macros]] +# path = "std::eprint" +# reason = "bypasses logging infrastructure, doesn't work in WASM, use Logger instead" diff --git a/jans-cedarling/http_utils/src/lib.rs b/jans-cedarling/http_utils/src/lib.rs index 7936429f9f3..9c131020c5e 100644 --- a/jans-cedarling/http_utils/src/lib.rs +++ b/jans-cedarling/http_utils/src/lib.rs @@ -46,6 +46,10 @@ pub enum HttpRequestError { MaxRetriesExceeded, #[error("failed to deserialize response to JSON: {0}")] DeserializeToJson(#[source] reqwest::Error), + #[error("failed to decode response body as text: {0}")] + DecodeResponseText(#[source] reqwest::Error), + #[error("failed to read response body bytes: {0}")] + DecodeResponseBytes(#[source] reqwest::Error), #[error("failed to initialize HTTP client: {0}")] InitializeHttpClient(#[source] reqwest::Error), } @@ -60,22 +64,15 @@ impl Sender { Self { backoff } } - /// Sends an HTTP request with retry logic then deserializes the JSON response to a - /// struct. - /// - /// This function attempts to send a request using the provided [`RequestBuilder`] - /// generator. If the request fails (e.g., due to network errors or non-success HTTP - /// status codes), it will retry the request with an exponentially increasing delay - /// between attempts. The function returns the successfully parsed JSON response or - /// an error if all retries fail. + /// Internal helper that sends an HTTP request with retry logic and returns the response. /// - /// # Notes - /// - The function retries on both network failures and HTTP error responses. - /// - The `RequestBuilder` must be **re-created** for each attempt because it cannot be reused. - pub async fn send(&mut self, mut request: F) -> Result + /// This is the core retry loop used by all public send methods. + async fn send_with_retry( + &mut self, + mut request: F, + ) -> Result where F: FnMut() -> RequestBuilder, - T: serde::de::DeserializeOwned, { let backoff = &mut self.backoff; backoff.reset(); @@ -83,8 +80,10 @@ impl Sender { loop { let response = match request().send().await { Ok(resp) => resp, - Err(err) => { - eprintln!("failed to complete HTTP request: {err}"); + Err(_err) => { + // Retry silently - callers receive the final error if all retries fail. + // TODO: add optional debug-level logging hook here once a logger can be + // passed in without pulling logging into this low-level crate. backoff .snooze() .await @@ -95,8 +94,10 @@ impl Sender { let response = match response.error_for_status() { Ok(resp) => resp, - Err(err) => { - eprintln!("received an HTTP error response: {err}"); + Err(_err) => { + // Retry silently - callers receive the final error if all retries fail. + // TODO: add optional debug-level logging hook here once a logger can be + // passed in without pulling logging into this low-level crate. backoff .snooze() .await @@ -105,12 +106,72 @@ impl Sender { }, }; - let response = response - .json::() - .await - .map_err(HttpRequestError::DeserializeToJson)?; - return Ok(response); } } + + /// Sends an HTTP request with retry logic then deserializes the JSON response to a + /// struct. + /// + /// This function attempts to send a request using the provided [`RequestBuilder`] + /// generator. If the request fails (e.g., due to network errors or non-success HTTP + /// status codes), it will retry the request with an exponentially increasing delay + /// between attempts. The function returns the successfully parsed JSON response or + /// an error if all retries fail. + /// + /// # Notes + /// - The function retries on both network failures and HTTP error responses. + /// - The `RequestBuilder` must be **re-created** for each attempt because it cannot be reused. + pub async fn send(&mut self, request: F) -> Result + where + F: FnMut() -> RequestBuilder, + T: serde::de::DeserializeOwned, + { + let response = self.send_with_retry(request).await?; + response + .json::() + .await + .map_err(HttpRequestError::DeserializeToJson) + } + + /// Sends an HTTP request with retry logic and returns the response body as text. + /// + /// This function attempts to send a request using the provided [`RequestBuilder`] + /// generator. If the request fails, it will retry with backoff. Returns the response + /// body as a UTF-8 string. + /// + /// # Notes + /// - The function retries on both network failures and HTTP error responses. + /// - The `RequestBuilder` must be **re-created** for each attempt because it cannot be reused. + pub async fn send_text(&mut self, request: F) -> Result + where + F: FnMut() -> RequestBuilder, + { + let response = self.send_with_retry(request).await?; + response + .text() + .await + .map_err(HttpRequestError::DecodeResponseText) + } + + /// Sends an HTTP request with retry logic and returns the response body as raw bytes. + /// + /// This function attempts to send a request using the provided [`RequestBuilder`] + /// generator. If the request fails, it will retry with backoff. Returns the response + /// body as raw bytes, useful for binary content like archives. + /// + /// # Notes + /// - The function retries on both network failures and HTTP error responses. + /// - The `RequestBuilder` must be **re-created** for each attempt because it cannot be reused. + pub async fn send_bytes(&mut self, request: F) -> Result, HttpRequestError> + where + F: FnMut() -> RequestBuilder, + { + let response = self.send_with_retry(request).await?; + response + .bytes() + .await + .map(|b| b.to_vec()) + .map_err(HttpRequestError::DecodeResponseBytes) + } }