/* Copyright 2021 Dominik George <dominik.george@teckids.org> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ use crate::config::{ get_optional }; use config::Config; use oauth2::{ AuthUrl, ClientId, ClientSecret, RequestTokenError, ResourceOwnerUsername, ResourceOwnerPassword, Scope, TokenResponse, TokenUrl }; use oauth2::basic::{ BasicClient, BasicTokenResponse }; use oauth2::reqwest::http_client; use std::error; use serde::{Deserialize, Serialize}; use reqwest; use serde_json; use jq_rs; /// Construct a configuration key from parts /// /// Simply joins all elements of the passed array with a '.' fn full_key(parts: Vec<&str>) -> String { parts.join(".") } /// Construct an OAuth2 client ready to exchange credentials /// /// Arguments /// --------- /// /// `conf` - reference to the config object /// `prefix` - current prefix (probably `nss` or `pam`) /// `error_value` - The value to return as Err result on error fn get_client<E: Copy>(conf: &Config, prefix: &str, error_value: E) -> Result<BasicClient, E> { // Get all the configuration parameters let client_id = ClientId::new(get_optional(&conf, &full_key(vec![prefix, "client_id"])).ok_or(error_value)?); let client_secret = match get_optional(&conf, &full_key(vec![prefix, "client_secret"])) { Some(v) => Some(ClientSecret::new(v)), None => None, }; let auth_url = match AuthUrl::new(get_optional(&conf, &full_key(vec![prefix, "auth_url"])).ok_or(error_value)?) { Ok(u) => u, _ => { error!("Could not parse authorization URL"); return Err(error_value); }, }; let token_url = match get_optional(&conf, &full_key(vec![prefix, "token_url"])) { Some(v) => match TokenUrl::new(v) { Ok(u) => Some(u), Err(_) => { error!("Could not parse token URL"); return Err(error_value); } }, None => None, }; // Construct an OAuth 2 client ready to exchange credentials let client = BasicClient::new(client_id, client_secret, auth_url, token_url); return Ok(client); } pub fn get_access_token_client<E: Copy>(conf: &Config, prefix: &str, error_value: E, unauth_value: E) -> Result<BasicTokenResponse, E> { let scopes: Vec<String> = match get_optional(&conf, &full_key(vec![prefix, "scopes"])) { Some(v) => v, None => vec![] }; let client = get_client(conf, prefix, error_value)?; let mut request = client.exchange_client_credentials(); for scope in scopes { request = request.add_scope(Scope::new(scope.to_string())); } let result = request.request(http_client); match result { Ok(t) => Ok(t), Err(e) => match e { RequestTokenError::ServerResponse(t) => { error!("Authorization server returned error: {}", t); return Err(unauth_value); }, _ => { error!("Error fetchin access token: {}", e); return Err(error_value); }, }, } } pub fn get_access_token_password<E: Copy>(conf: &Config, prefix: &str, username: String, password: String, error_value: E, unauth_value: E) -> Result<BasicTokenResponse, E> { let scopes: Vec<String> = match get_optional(&conf, &full_key(vec![prefix, "scopes"])) { Some(v) => v, None => vec![] }; let res_username = ResourceOwnerUsername::new(username); let res_password = ResourceOwnerPassword::new(password); let client = get_client(conf, prefix, error_value)?; let mut request = client.exchange_password(&res_username, &res_password); for scope in scopes { request = request.add_scope(Scope::new(scope.to_string())); } let result = request.request(http_client); match result { Ok(t) => Ok(t), Err(e) => match e { RequestTokenError::ServerResponse(t) => { error!("Authorization server returned error: {}", t); return Err(unauth_value); }, _ => { error!("Error fetchin access token: {}", e); return Err(error_value); }, }, } } /// Retrieve text data from a configured URL endpoint /// /// Takes the same arguments as `get_data_jq`. fn get_data(conf: &Config, prefix: &str, endpoint: &str, param: String, token: &BasicTokenResponse) -> Result<String, Box<dyn error::Error>> { /// Extract token as string from deserialized access token let access_token = token.access_token().secret(); let token_type = "Bearer".to_string(); // FIXME Probably we need to handle other token types // Retreieve endpoint URL from configuration let mut endpoint_url: String = get_optional(&conf, &full_key(vec![prefix, "urls", endpoint])).ok_or("")?; endpoint_url = endpoint_url.replace("{}", ¶m); debug!("Loading text data from {}", endpoint_url); // Send off request to server let client = reqwest::blocking::Client::new(); Ok(client .get(&endpoint_url) .header(reqwest::header::AUTHORIZATION, format!("{} {}", token_type, access_token)) .send()? .text()?) } /// Retrieve a jq program from the config /// /// Arguments /// --------- /// /// `conf` - reference to the config object /// `prefix` - current prefix (probably `nss` or `pam`) /// `endpoint` - name of the URL endpoint operating on, e.g. `passwd.list` /// `multi` - `true` if the nndpoint will return an array /// `rev` - `true` if looking for the reverse mapping program (see `get_data_jq`) /// /// The endpoint will be truncated starting from the right if no program is found under /// the config key, so that a single jq programm can be configured for `passwd` instead of /// three separate programs for `passwd.list`, `passwd.by_uid` and `passwd.by_name`. /// /// Lookup of the program in the config is done through the general config fetching mechanism /// (see `get_optional`). /// /// The programm will be looked up unter `<prefix>.maps`. If `rev` is true, the programm will /// be looked up under `maps.rev` instead of `maps`. /// /// If `multi` is `true`, the resulting program will be wrapped into jq's `map()` function /// to be applied to an array of objects. fn get_jq_prog(conf: &Config, prefix: &str, endpoint: &str, multi: bool, rev: bool) -> jq_rs::Result<jq_rs::JqProgram> { // Generate config key to find jq program under let prog_key = match rev { false => full_key(vec![prefix, "maps", endpoint]), true => full_key(vec![prefix, "maps.rev", endpoint]), }; // Retrieve jq program code from config let mut jq_code: String = match get_optional(&conf, &prog_key) { // We got the value immediately Some(v) => v, None => { if rev { // Do not fallback to more generic program for reverse mapping ".".to_string() } else { // Try falling back to more generic program match endpoint.find('.') { Some(i) => { debug!("JQ mapping program for {} not found; trying more generic definition", endpoint); // Call recursively (and return verbatim, as we get a compiled program) return get_jq_prog(conf, prefix, &endpoint[..i], multi, rev) } // Lookup failed ultimately; fallback to jq "identity" (no-op) None => ".".to_string() } } } }; // If multi mode is passed, the program will be applied to an array of entries // We thus wrap it in a call to jq's `map()` if multi { jq_code = "map(".to_string() + &jq_code + ")"; } debug!("Compiling JQ program for endpoint {}: {}", endpoint, jq_code); jq_rs::compile(&jq_code) } /// Retrieve JSON data from a configured endpoint, transforming using configured jq programs /// /// This function takes a prefix (probably `nss` or `pam`, and an endpoint name to lookup /// information in the passed configuration. It then uses this information and the passed /// parameter to construct a URL to retrieve data from the HTTP API. /// /// Transformation occurs both for the passed parameter and for the retrieved data. /// Consider the following scenario: /// /// The configured backend server provides three URLs for doing NSS-like lookups: /// /// * `https://example.com/users/` - get a JSON array of all users /// * `https://example.com/user/1234/` - get a single JSON object for a user with a numeric user ID /// * `https://example.com/user/janedoe/` - get a single JSON object for a user with a user name /// /// Any user object returned by the server looks like this: /// /// ```json /// { /// "username": "janedoe", /// "uid": 1234, /// "primary_gid": 100, /// "home_directory": "/home/janedoe", /// "login_shell": "/bin/bash", /// "full_name": "Doe, Jane" /// } /// ``` /// /// To integrate the information in our system, which also has other user sources, /// we need to ensure the following: /// /// * Numeric user IDs need to be mapped starting at 10000 /// * Home directories need to be re-mapped to /srv/remote_users /// * The full name (GECOS) shall be suffixed with "(Remote)" for clarity /// * We need to set a static password of "x" because the API (correctly) does not serve passwords /// * All field names need to be renamed to the fields of the passwd struct /// /// To accomplish this, we will configure two jq programs: /// /// ```toml /// nss.maps.passwd = """ /// { /// name: .username, /// # No passwords in passwd /// passwd: "x", /// # Map user and group IDs starting at 10000 /// uid: (.uid + 10000), /// gid: (.primary_gid + 10000), /// # Append organisation name to Gecos field /// gecos: (.full_name + " (Remote)"), /// # Remap /home from server to /srv/teckids locally /// dir: ("/srv/remote_users/" + (.home_directory|ltrimstr("/home/"))), /// shell: .login_shell /// } /// """ /// /// # Reverse mapping to make sure uid lookups on entries mapped above still work /// nss.maps.rev.passwd.by_uid = ". - 10000" /// ``` /// /// The first program maps result objects into new JSON objects with the rules described /// inline. The `map()` function from jq that is needed to apply the program to every /// object in an array will be added automatically if a list is retrieved (`multi` is `true`). /// /// The second program is used when a single user is queried by their numeric user ID, and it /// reverses the transformation done to the uid field so the lookup on the server gets the /// expected user ID. /// /// Along with the following URL configuration, this brings everything into place: /// /// ```toml /// nss.urls.passwd.list = "https://example.com/users/" /// nss.urls.passwd.by_uid = "https://example.com/users/{}/" /// nss.urls.passwd.by_name = "https://example.com/users/{}/" /// ``` /// /// In the second and thrid URL, the placeholder `{}` will be replaced with the lookup key. pub fn get_data_jq<T: for<'de> Deserialize<'de>, V: Serialize>(conf: &Config, prefix: &str, endpoint: &str, param: V, token: &BasicTokenResponse, multi: bool) -> Result<T, Box<dyn error::Error>> { // Get jq mapping programs for forward and reverse mappings let mut jq_prog_fwd = get_jq_prog(&conf, prefix, endpoint, multi, false)?; let mut jq_prog_rev = get_jq_prog(&conf, prefix, endpoint, false, true)?; // Convert and transform the passed param using the reverse JQ mapping program // 1. Serialize into JSON value (atomic) to be bale to pass into jq let param = serde_json::to_string(¶m)?; // 2. Transform using the JQ program loaded above let param = jq_prog_rev.run(¶m)?.trim().to_string(); // 3. Deserialize into serde_json value so we get numbers as numbers, strings properly unquoted let param: serde_json::Value = serde_json::from_str(¶m)?; let param = match param { serde_json::Value::String(v) => v, // We want strings verbatim without JSON quoting _ => param.to_string() // We want numbers converted to string }; // Retrieve data via HTTP and transform using jq forward mapping program let data = get_data(&conf, prefix, endpoint, param, token)?; let data = jq_prog_fwd.run(&data)?; // Deserialize transformed JSON and return Ok(serde_json::from_str(&data)?) }