Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .jules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ This document provides context and guidelines for Google Jules when writing, ref
2. **Tokio for Async Tests**:
* Use `#[tokio::test]` for asynchronous test cases.
3. **Mockito**:
* Optionally used in older tests to mock external HTTP responses (though Axum is preferred for Keycloak API mocking).
* Optionally used in older tests to mock external HTTP responses (though Axum is preferred for Keycloak API mocking). Can also be used to test request timeout scenarios by using `with_chunked_body` to introduce simulated response delays.
4. **Tempfile**:
* Use `tempfile::tempdir()` to create temporary workspaces for reading/writing configuration files during tests to avoid polluting the host system.
5. **Cargo Tarpaulin**:
* Used to measure test coverage. Run coverage checks with:
* Used to measure test coverage. The codebase maintains a strict **95% minimum code coverage** policy. Run coverage checks with:
```bash
cargo tarpaulin --out Xml
cargo tarpaulin
```

## Guidelines for Writing Tests
Expand All @@ -37,3 +37,4 @@ This document provides context and guidelines for Google Jules when writing, ref
1. A validation test in [validate_test.rs](tests/validate_test.rs).
2. An integration test verifying planning and reconciliation in [plan_test.rs](tests/plan_test.rs) or [apply_test.rs](tests/apply_test.rs).
3. A model coverage test in [models_coverage_test.rs](tests/models_coverage_test.rs).
4. Verification that total project code coverage meets the **95% threshold**. Avoid writing "fake" or empty test cases to inflate coverage; all tests must validate realistic execution, errors, or configuration edge cases.
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ All business logic is located in the `src/` directory:
* [`src/models.rs`](src/models.rs): Strongly-typed Serde representations of Keycloak resources. Implements the `KeycloakResource` and `ResourceMeta` traits.
* [`src/inspect.rs`](src/inspect.rs): Scans the remote Keycloak instance and serializes resources into local workspace files using a parallelized pipeline.
* [`src/plan/`](src/plan/): Calculates diffs and writes the plan. Uses the generic planning engine in `generic.rs`. Supports collapsed unified diff formatting (3 context lines) by default, `--verbose` full diff view, and interactive expansion choices during confirmation.
* [`src/apply/`](src/apply/): Reconciles resources. Uses the generic reconciliation engine in `generic.rs` and stage-specific modules.
* [`src/apply/`](src/apply/): Reconciles resources. Uses the generic reconciliation engine in `generic.rs` and stage-specific modules. Supports optional pruning/deletion of orphaned remote resources via the `--prune` flag.
* [`src/validate.rs`](src/validate.rs): Validates local configurations against expected structures and constraints.
* [`src/clean.rs`](src/clean.rs): Removes unreferenced or invalid configuration files from the workspace.
* [`src/init.rs`](src/init.rs): Scaffolds the initial `kaji.toml` / `.kaji.toml` configuration files.
Expand Down Expand Up @@ -66,7 +66,7 @@ Multi-environment configurations are managed using the `--profile` (`-p`) flag:

## ⚙️ Project Configuration File (`kaji.toml` / `.kaji.toml`)

Project connection defaults and workspace parameters can be declared inside `kaji.toml` or `.kaji.toml` files in the current working directory. The configuration file is parsed at startup and merged with command-line inputs.
Project connection defaults, request timeouts, and workspace parameters can be declared inside `kaji.toml` or `.kaji.toml` files in the current working directory. The configuration file is parsed at startup and merged with command-line inputs.

Settings are resolved in the following precedence order:
1. **CLI Flags** (highest)
Expand Down
6 changes: 3 additions & 3 deletions GEMINI.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ This document serves as the internal developer guide for `kaji`. It explains the
1. **Desired State**: Defined in local YAML files within the workspace. Support for **Environment Profiles & Overlays** allows for multi-environment configurations (e.g., `realm.yaml` + `realm.prod.yaml`).
2. **Current State**: Fetched from the Keycloak Admin API.
3. **Diff Engine (`src/plan/`)**: Compares the two states to identify what needs to be Created, Updated, or Deleted. It generates a `.kajiplan` file in the workspace containing the list of files that have pending changes.
4. **Reconciler (`src/apply/`)**: Executes the necessary API calls to bring the Current State in line with the Desired State. It uses **Dependency-Aware (Staged) Reconciliation** to ensure resources are applied in the correct order (e.g., Realms before Roles, Roles before Users).
4. **Reconciler (`src/apply/`)**: Executes the necessary API calls to bring the Current State in line with the Desired State. It uses **Dependency-Aware (Staged) Reconciliation** to ensure resources are applied in the correct order (e.g., Realms before Roles, Roles before Users). Supports optional pruning/deletion of orphaned remote resources not declared in the workspace configuration using the `--prune` flag (excluding protected system resources like default clients/roles).

### Core Modules

Expand Down Expand Up @@ -67,7 +67,7 @@ For any resource `resource.yaml`, `kaji` looks for `resource.{profile}.yaml` and
`kaji` supports project-level settings to override defaults and connection variables persistently.

### Architecture & Pipeline
1. **Schema Definition**: The `Config` struct is defined in [src/args.rs](src/args.rs). It represents optional fields for Keycloak connection credentials, vault parameters, and the workspace folder.
1. **Schema Definition**: The `Config` struct is defined in [src/args.rs](src/args.rs). It represents optional fields for Keycloak connection credentials, vault parameters, the workspace folder, and request timeouts.
2. **File Lookup**: In [src/lib.rs](src/lib.rs), `load_config_file` checks for:
* A custom config path specified via `--config` CLI flag or `KAJI_CONFIG` env var.
* `kaji.toml` in the current working directory.
Expand Down Expand Up @@ -133,7 +133,7 @@ Located in `benches/`. Used to monitor performance for large workspaces with tho
Secret handling is managed via the `SecretResolver` trait, which allows for multiple resolution strategies:

- **EnvResolver**: Resolves `${VAR_NAME}` from the environment or a `.secrets` file.
- **VaultResolver**: Resolves `${vault:mount/path#field}` from a HashiCorp Vault KV2 engine.
- **VaultResolver**: Resolves `${vault:mount/path#field}` from a HashiCorp Vault KV2 engine. Utilizes a thread-safe in-memory cache (`tokio::sync::Mutex<HashMap>`) to avoid duplicate/redundant HTTP GET requests to Vault for the same secret path during planning/applying.
- **CompositeResolver**: Chains multiple resolvers in a prioritized order.

The masking heuristic during `inspect` looks for keys matching these patterns:
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ server_url: "https://keycloak.prod.example.com"
client_id: "kaji-cli"
client_secret: "${PROD_KAJI_SECRET}"
secrets_file: ".secrets.prod" # Load environment secrets from this file
timeout: 30 # Keycloak request timeout in seconds (optional)
```

### 2. Use Overlays
Expand Down Expand Up @@ -159,6 +160,7 @@ When running with `--profile prod`, `kaji` deep-merges the overlay onto the base
| `KEYCLOAK_CLIENT_SECRET` | Client Secret (if using client credentials) | |
| `VAULT_ADDR` | HashiCorp Vault URL | |
| `VAULT_TOKEN` | HashiCorp Vault Token | |
| `KEYCLOAK_TIMEOUT` | Keycloak request timeout in seconds | `10` |

### Configuration File (`kaji.toml` / `.kaji.toml`)

Expand Down Expand Up @@ -187,6 +189,9 @@ workspace = "kaji-workspace"
# HashiCorp Vault Settings
vault_addr = "http://vault:8200"
vault_token = "my-vault-token"

# Keycloak request timeout in seconds (optional)
timeout = 10
```

#### Precedence / Priority Rules
Expand Down Expand Up @@ -252,6 +257,9 @@ kaji apply --profile prod --yes

# Review mode: confirm each change before application
kaji apply --profile prod --review

# Prune mode: delete remote resources that are not declared in local config files
kaji apply --profile prod --prune
```

### `drift`
Expand Down
4 changes: 3 additions & 1 deletion benches/bench_authenticator_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,14 @@ fn bench_apply_auth_configs(c: &mut Criterion) {
apply_authenticator_configs(
&client,
&workspace_dir,
Arc::new(workspace_dir.join(".secrets")),
resolver.clone(),
Arc::new(None),
"",
"test-realm",
None,
false,
ui.clone(),
false,
)
.await
.unwrap();
Expand Down
2 changes: 1 addition & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ <h3>Dependency-Aware Reconciliation</h3>
<article class="feature-card" id="feat-profiles">
<div class="feature-icon-wrap"><span class="feature-icon"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span></div>
<h3>Profiles &amp; TOML Config</h3>
<p>Persist connection credentials, workspace paths, and target environments cleanly. Support for profiles, overlays, and project-wide configuration files.</p>
<p>Persist connection credentials, request timeouts, workspace paths, and target environments cleanly. Support for profiles, overlays, and project-wide configuration files.</p>
<ul class="feature-list">
<li><span class="check">&#x2713;</span> kaji.toml / .kaji.toml project config</li>
<li><span class="check">&#x2713;</span> profiles/dev.yaml, staging.yaml, prod.yaml</li>
Expand Down
175 changes: 174 additions & 1 deletion src/apply/generic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub async fn apply_resources<T>(
review: bool,
ui: Arc<dyn Ui>,
yes: bool,
prune: bool,
) -> Result<()>
where
T: KeycloakResource
Expand All @@ -48,7 +49,7 @@ where
.with_context(|| format!("Failed to get {} for realm '{}'", T::LABEL, realm_name))?;

let existing_map: HashMap<String, String> = existing_resources
.into_iter()
.iter()
.filter_map(|r| {
let identity = r.get_identity();
let id = r.get_id();
Expand Down Expand Up @@ -198,9 +199,111 @@ where

crate::utils::join_all_tasks(set, None).await?;
pb.finish_with_message(format!("Applied {}", T::LABEL));

if prune {
let mut declared = HashSet::new();
if async_fs::try_exists(&resources_dir).await? {
let mut entries = async_fs::read_dir(&resources_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "yaml") {
continue;
}
if is_overlay_file(&path, profile.as_deref()) {
continue;
}
if let Ok(content) = async_fs::read_to_string(&path).await {
if let Ok(val) = serde_yaml::from_str::<serde_json::Value>(&content) {
if let Ok(rep) = serde_json::from_value::<T>(val) {
if let Some(identity) = rep.get_identity() {
declared.insert(identity);
}
}
}
}
}
}

for remote in &existing_resources {
if let (Some(identity), Some(id)) = (remote.get_identity(), remote.get_id()) {
if !declared.contains(&identity) {
if is_protected_resource::<T>(&identity, realm_name) {
continue;
}

let proceed = if yes {
true
} else {
ui.confirm(
&format!("Prune/Delete remote {} '{}'?", T::LABEL, remote.get_name()),
false,
)?
};

if proceed {
client.delete_resource::<T>(id).await.with_context(|| {
format!("Failed to prune {} '{}'", T::LABEL, remote.get_name())
})?;
println!(" Removed/Pruned {} {}", T::LABEL, remote.get_name());
}
}
}
}
}

Ok(())
}

fn is_protected_resource<T>(identity: &str, realm_name: &str) -> bool
where
T: KeycloakResource,
{
let path = T::API_PATH;
if path == "clients" {
let protected = [
"admin-cli",
"security-admin-console",
"account",
"account-console",
"broker",
"realm-management",
];
protected.contains(&identity)
} else if path == "roles" {
let default_role = format!("default-roles-{}", realm_name);
let protected = ["offline_access", "uma_authorization", &default_role];
protected.contains(&identity)
} else if path == "client-scopes" {
let protected = [
"profile",
"email",
"address",
"phone",
"offline_access",
"roles",
"web-origins",
"microprofile-jwt",
];
protected.contains(&identity)
} else if path == "authentication/flows" {
let protected = [
"browser",
"direct grant",
"registration",
"registration form",
"reset credentials",
"clients",
"first broker login",
"saml ecp",
"docker auth",
"http challenge",
];
protected.contains(&identity)
} else {
false
}
}

pub async fn check_and_update_enrichment<T>(
_client: &KeycloakClient,
path: &std::path::Path,
Expand Down Expand Up @@ -492,4 +595,74 @@ mod tests {

Ok(())
}

#[test]
fn test_is_protected_resource_branches() {
use crate::models::{
AuthenticationFlowRepresentation, ClientRepresentation, ClientScopeRepresentation,
GroupRepresentation, RoleRepresentation,
};

// clients
assert!(is_protected_resource::<ClientRepresentation>(
"admin-cli",
"myrealm"
));
assert!(is_protected_resource::<ClientRepresentation>(
"security-admin-console",
"myrealm"
));
assert!(is_protected_resource::<ClientRepresentation>(
"account", "myrealm"
));
assert!(!is_protected_resource::<ClientRepresentation>(
"my-custom-client",
"myrealm"
));

// roles
assert!(is_protected_resource::<RoleRepresentation>(
"offline_access",
"myrealm"
));
assert!(is_protected_resource::<RoleRepresentation>(
"default-roles-myrealm",
"myrealm"
));
assert!(!is_protected_resource::<RoleRepresentation>(
"my-custom-role",
"myrealm"
));

// client-scopes
assert!(is_protected_resource::<ClientScopeRepresentation>(
"profile", "myrealm"
));
assert!(is_protected_resource::<ClientScopeRepresentation>(
"roles", "myrealm"
));
assert!(!is_protected_resource::<ClientScopeRepresentation>(
"my-custom-scope",
"myrealm"
));

// authentication flows
assert!(is_protected_resource::<AuthenticationFlowRepresentation>(
"browser", "myrealm"
));
assert!(is_protected_resource::<AuthenticationFlowRepresentation>(
"direct grant",
"myrealm"
));
assert!(!is_protected_resource::<AuthenticationFlowRepresentation>(
"my-custom-flow",
"myrealm"
));

// other (e.g. groups)
assert!(!is_protected_resource::<GroupRepresentation>(
"my-custom-group",
"myrealm"
));
}
}
Loading