Skip to content

Commit

Permalink
Add --test mode
Browse files Browse the repository at this point in the history
  • Loading branch information
elonen committed Feb 23, 2023
1 parent d7dabe8 commit da5ca42
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 70 deletions.
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "ldap_authz_proxy"
version = "0.3.0"
version = "0.3.1"
edition = "2021"

description = "LDAP authorization proxy for authenticated HTTP users"
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,14 @@ The server can be run with `ldap_authz_proxy <configfile>`. Additional
options are available (`--help`):

```
ldap_authz_proxy -- HTTP proxy server for LDAP authorization, mainly for Nginx
This program is a HTTP proxy server that checks the authorization of an
already authenticated user against an LDAP server. It can be used to return
attributes from LDAP (or user custom) to the Nginx in HTTP headers.
Usage:
ldap_authz_proxy [options] <config_file>
ldap_authz_proxy -t | --test [options] <config_file> <username> <uri_path>
ldap_authz_proxy -h | --help
ldap_authz_proxy -H | --help-config
ldap_authz_proxy -v | --version
Expand All @@ -167,7 +173,12 @@ Options:
-j --json Log in JSON format
-d --debug Enable debug logging
--dump-config Dump parse configuration in debug format and exit
--dump-config Check configuration file, dump parsed
values to stdout if successful, and exit.
-t --test Test mode. Query LDAP for given username and URI,
then exit with 0 if the user is authorized (HTTP 200)
or with HTTP status code (e.g. 403) otherwise.
-h --help Show this screen.
-H --help-config Show help for the configuration file.
Expand Down Expand Up @@ -427,6 +438,9 @@ cargo run -- example.ini --debug &
# Test request directly against ldap_authz_proxy
curl http://127.0.0.1:10567/admins -H "X-Ldap-Authz-Username:alice" -I

#...or use --test option to avoid curl:
cargo run -- example.ini --debug --test alice "/admins"

# Cleanup
kill %1 # Or do: fg + ctrl-c
cd test
Expand Down
12 changes: 11 additions & 1 deletion debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
ldap_authz_proxy (0.3.1-1) unstable; urgency=low

* Bugfix: attribute joining in sub-query results
* New features:
- Add --test option to try out a query without starting the server
- Allow lists in config file to be split into multiple lines
- Retry LDAP queries a few times on some temporary errors

-- Jarno Elonen <[email protected]> Tue, 23 Feb 2023 10:30:00 +0000

ldap_authz_proxy (0.3.0-1) unstable; urgency=low

* Rename some config options for clarity, add a few new ones
Expand All @@ -11,7 +21,7 @@ ldap_authz_proxy (0.3.0-1) unstable; urgency=low
- Support quoting in config file
- Configurable attribute delimiter in response headers

-- Jarno Elonen <[email protected]> Tue, 22 Feb 2023 23:14:00 +0000
-- Jarno Elonen <[email protected]> Tue, 21 Feb 2023 23:14:00 +0000

ldap_authz_proxy (0.2.1-1) unstable; urgency=low

Expand Down
4 changes: 1 addition & 3 deletions example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,10 @@ http_path = "/users$"
query_vars = "MY_CUSTOM_VAR = ACL_Users"
; Fetch additional attributes from LDAP my performing additional queries
; if this one succeeds. See below for section definitions.
sub_queries = "is_beta_tester, is_bug_reporter"
sub_queries = "is_peer_support"
sub_queries = "is_beta_tester, is_bug_reporter, is_peer_support"

[admins]
http_path = "/admins$"
http_path = "/admins$"
query_vars = "MY_CUSTOM_VAR = ACL_Admins"
; Fictional example: instruct backend app to show debug info for admins
set_attribs_on_success = "extraGroups = show_debug_info"
Expand Down
31 changes: 26 additions & 5 deletions run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,26 @@ function request() {
}

function test() {
FOLDER="$1"
URI="$1"
CREDS="$2"
EXPECTED="$3"
ACTUAL="$(request $FOLDER $CREDS)"
ACTUAL="$(request $URI $CREDS)"
if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "Test FAILED - expected '$EXPECTED', got '$ACTUAL' for '$FOLDER' with '$CREDS'"
echo "Test FAILED - expected '$EXPECTED', got '$ACTUAL' for '$URI' with '$CREDS'"
else
echo "Test OK for '$URI' with '$CREDS'"
fi
}

function test_offline() {
URI="$1"
USER="$2"
EXPECTED_CODE="$3"
EXIT_CODE=$(docker compose exec www target/debug/ldap_authz_proxy --test example.ini $USER $URI | grep -o "HTTP [0-9]*")
if [ "$EXIT_CODE" = "HTTP $EXPECTED_CODE" ]; then
echo "Test OK for --test test with '$URI' with '$USER'"
else
echo "Test OK for '$FOLDER' with '$CREDS' ($ACTUAL == $EXPECTED)"
echo "Test FAILED for --test test with '$URI' with '$USER' (expected $EXPECTED_CODE, got $EXIT_CODE)"
fi
}

Expand All @@ -88,10 +100,19 @@ function do_tests() {
# Test username quoting with malicious characters, should give 401, not 500
test "user-page" ")=&%)):password" "401 c eg:"

echo "(Repeat and check that query came from cache)"
echo "-- Repeat and check that query came from cache"
test "user-page" "alice:alice123" "200Alice Alison c1 eg:beta_tester"
test "admin-page" "alice:alice123" "200 c1 eg:show_debug_info"
test "user-page" "bob:bob123" "200Bob Bobrikov c1 eg:bug_reporter;peer_support;show_debug_info"

echo "-- Test --test mode"
test_offline "/users" "alice" 200
test_offline "/admins" "alice" 200
test_offline "/users" "bob" 200
test_offline "/admins" "bob" 403
test_offline "/users" "charlie" 200
test_offline "/admins" "charlie" 403
test_offline "/BADPAGE" "alice" 404
}

# Run the tests and summarize
Expand Down
79 changes: 45 additions & 34 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,28 @@ macro_rules! config_options {
)*
}

fn is_multiline(opt: &str) -> bool {
const CONFIG_OPTIONS: &[&str] = &[ $(stringify!($name),)* ];

fn option_is_list(opt: &str) -> bool {
match opt {
$(
$( stringify!($name) => { assert!(stringify!($multi) == "MULTILINE"); true } )?
)*
$( $( stringify!($name) => { assert!(stringify!($multi) == "MULTILINE"); true } )? )*
_ => false,
}
}

fn help_for_option(key: &str) -> &str {
match key {
$( stringify!($name) => $help, )*
_ => "<Unknown option>",
}
}

const CONFIG_OPTIONS: &[(&str, &str, Option<&str>)] = &[
$(
(stringify!($name), $help, $default),
)*
];
fn default_for_option(key: &str) -> Option<&str> {
match key {
$( stringify!($name) => $default, )*
_ => None,
}
}

const CONFIG_HELP_INTRO: &str = r##"
Configuration file in in INI format:
Expand All @@ -58,10 +65,15 @@ Configuration file in in INI format:
Every section must have a unique name.
Descriptions of possible config options follows. Options marked with '(+)'
are comma-separated lists - if specified multiple times, their values are
concatenated.
Options containing a comma separated list (marked (+)) can be specified
multiple times. These examples are equivalent:
ldap_attribs = CN, displayName, givenName, sn, mail
ldap_attribs = CN, displayName, givenName
ldap_attribs = sn, mail
Config options:
"##;

pub fn get_config_help() -> String {
Expand All @@ -72,20 +84,16 @@ concatenated.
}
}
CONFIG_HELP_INTRO.to_string() + &CONFIG_OPTIONS.iter()
.filter(|(key, _, _)| *key != "section")
.map(|(key, help, def)| format!(" {}{} {}\n\n {}\n\n\n",
.filter(|key| **key != "section")
.map(|key| format!(" {}{} {}\n\n {}\n\n\n",
key,
if is_multiline(key) { " (+)" } else { "" },
fmt_def(def),
help.replace("\n", "\n ")
if option_is_list(key) { " (+)" } else { "" },
fmt_def( &default_for_option( &key )),
help_for_option(key).replace("\n", "\n ")
))
.collect::<String>()
}

fn help_for_key(key: &str) -> &str {
CONFIG_OPTIONS.iter().find(|(k, _, _)| k == &key).map(|(_, v, _)| *v).unwrap()
}

pub (crate) fn dump_config(conf: &ConfigSection) -> String {
let mut res = format!("[{}]\n", conf.section);
$(
Expand Down Expand Up @@ -169,12 +177,18 @@ pub(crate) fn parse_config(config_file: &str) -> Result<Vec<ConfigSection>, Erro
let mut defaults = HashMap::new();
// ...from the [default] section
if let Some(default_sect) = ini.section(Some("default")) {
defaults.extend(default_sect.iter().map(|(k, v)| (k.to_string(), Some(v.to_string()))));
defaults = default_sect.iter().map(|(key, _)|
(key.to_string(), Some(
// Join values from multiple config lines with a comma
default_sect.get_all(key)
.map(|v| v.trim()).filter(|v| !v.is_empty()).collect::<Vec<_>>()
.join(", ").trim().to_string()
))).collect();
}
// ..from built-in defaults
for (key, _, def) in CONFIG_OPTIONS.iter() {
for key in CONFIG_OPTIONS.iter() {
if !defaults.contains_key(*key) {
if let Some(def) = def {
if let Some(def) = default_for_option(key) {
defaults.insert(key.to_string(), Some(def.to_string()));
}
}
Expand All @@ -199,16 +213,16 @@ pub(crate) fn parse_config(config_file: &str) -> Result<Vec<ConfigSection>, Erro
// Check that no unknown keys are set
let unknown_keys = sect_props.iter()
.map(|(key, _)| key)
.filter(|key| !CONFIG_OPTIONS.iter().any(|(k, _, _)| k == key))
.filter(|key| !CONFIG_OPTIONS.iter().any(|k| k == key))
.collect::<Vec<_>>();
if !unknown_keys.is_empty() {
bail!("Unknown key(s) in section [{}]: {}", &section_name, unknown_keys.join(", "));
}

// Only allow certain keys to appear multiple times
for (key, _) in sect_props.iter() {
if sect_props.get_all(key).count() > 1 && !is_multiline(key) {
bail!("Key '{}' (in section [{}]) is not a list, and therefore cannot appear mutiple times.", key, section_name);
if sect_props.get_all(key).count() > 1 && !option_is_list(key) {
bail!("Key '{}' (in section [{}]) defined mutiple times. This is allowed only for list options.", key, section_name);
}
}

Expand All @@ -227,8 +241,8 @@ pub(crate) fn parse_config(config_file: &str) -> Result<Vec<ConfigSection>, Erro

// Check that all required keys are set
let missing_keys = CONFIG_OPTIONS.iter()
.filter(|(key, _, _)| !sect_props.contains_key(*key))
.map(|(key, _, _)| *key)
.filter(|key| !sect_props.contains_key(*key))
.map(|key| *key)
.filter(|key| key != &"section")
.collect::<Vec<_>>();
if !missing_keys.is_empty() {
Expand All @@ -239,18 +253,15 @@ pub(crate) fn parse_config(config_file: &str) -> Result<Vec<ConfigSection>, Erro
let get = |key: &str| sect_props.get_all(key)
.map(|v| v.trim())
.filter(|v| !v.is_empty())
.collect::<Vec<_>>()
.join(", ")
.trim()
.to_string();
.collect::<Vec<_>>().join(", ").trim().to_string();

// Compile regex
let http_path = get("http_path");
let http_path_re = if http_path.trim().is_empty() { None } else {
Some(Regex::new(&http_path).map_err(|e| anyhow!("Invalid regex in http_path: {}", e))?) };

let parse_err = |key: &str| -> Error {
anyhow!("Invalid value for option '{key}' in section [{section_name}]: {}.\n -- {}", get(key), help_for_key(key))
anyhow!("Invalid value for option '{key}' in section [{section_name}]: {}.\n -- {}", get(key), help_for_option(key))
};

/// Parse a comma-separated list of assignments.
Expand Down
Loading

0 comments on commit da5ca42

Please sign in to comment.