Compare commits

...

5 commits

Author SHA1 Message Date
Merlijn 8bc1789f66
Merge 91307dc62e into 9bf17c4846 2025-02-17 12:32:50 +00:00
Alex Martens 9bf17c4846
book: add OAuth2 Proxy example () 2025-02-16 05:14:47 +00:00
Firstyear ed88b72080
Exempt idm_admin and admin from denied names. ()
idm_admin and admin should be exempted from the denied names process,
as these values will already be denied due to attribute uniqueness.
Additionally improved the denied names check to only validate the
name during a change, not during a modifification. This way entries
that become denied can get themself out of the pickle.
2025-02-15 22:45:25 +00:00
ToxicMushroom 91307dc62e
patch up rebase 2025-02-13 23:46:35 +01:00
ToxicMushroom 4e4fd8dfa7
- Form validation
- Editable emails
- Basic profile updating
2025-02-10 14:14:17 +01:00
16 changed files with 700 additions and 66 deletions

14
Cargo.lock generated
View file

@ -391,6 +391,7 @@ dependencies = [
"multer",
"pin-project-lite",
"serde",
"serde_html_form",
"tower 0.5.2",
"tower-layer",
"tower-service",
@ -5064,6 +5065,19 @@ dependencies = [
"syn 2.0.98",
]
[[package]]
name = "serde_html_form"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4"
dependencies = [
"form_urlencoded",
"indexmap 2.7.1",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_json"
version = "1.0.138"

View file

@ -556,6 +556,65 @@ php occ config:app:set --value=0 user_oidc allow_multiple_user_backends
You can login directly by appending `?direct=1` to your login page. You can re-enable other backends
by setting the value to `1`
## OAuth2 Proxy
OAuth2 Proxy is a reverse proxy that provides authentication with OpenID Connect identity providers.
It is typically used to secure web applications without native OpenID Connect support.
Prepare the environment.
Due to a [lack of public client support](https://github.com/oauth2-proxy/oauth2-proxy/issues/1714) we have to set it up as a basic client.
```bash
kanidm system oauth2 create webapp 'webapp.example.com' 'https://webapp.example.com'
kanidm system add-redirect-url webapp 'https://webapp.example.com/oauth2/callback'
kanidm system oauth2 update-scope-map webapp email openid
kanidm system oauth2 get webapp
kanidm system oauth2 show-basic-secret webapp
<SECRET>
```
Create a user group.
```bash
kanidm group create 'webapp_admin'
```
Setup the claim-map to add `webapp_group` to the userinfo claim.
```bash
kanidm system oauth2 update-claim-map-join 'webapp' 'webapp_group' array
kanidm system oauth2 update-claim-map 'webapp' 'webapp_group' 'webapp_admin' 'webapp_admin'
```
Authorize users for the application.
Additionally OAuth2 Proxy requires all users have an email, reference this issue for more details:
- <https://github.com/oauth2-proxy/oauth2-proxy/issues/2667>
```bash
kanidm person update '<user>' --legalname 'Personal Name' --mail 'user@example.com'
kanidm group add-members 'webapp_admin' '<user>'
```
And add the following to your OAuth2 Proxy config.
```toml
provider = "oidc"
scope = "openid email"
# change to match your kanidm domain and client id
oidc_issuer_url = "https://idm.example.com/oauth2/openid/webapp"
# client ID from `kanidm system oauth2 create`
client_id = "webapp"
# redirect URL from `kanidm system add-redirect-url webapp`
redirect_url = "https://webapp.example.com/oauth2/callback"
# claim name from `kanidm system oauth2 update-claim-map-join`
oidc_groups_claim = "webapp_group"
# user group from `kanidm group create`
allowed_groups = ["webapp_admin"]
# secret from `kanidm system oauth2 show-basic-secret webapp`
client_secret = "<SECRET>"
```
## Outline
> These instructions were tested with self-hosted Outline 0.80.2.

View file

@ -24,7 +24,7 @@ askama = { workspace = true, features = ["with-axum"] }
askama_axum = { workspace = true }
axum = { workspace = true }
axum-htmx = { workspace = true }
axum-extra = { version = "0.9.6", features = ["cookie"] }
axum-extra = { version = "0.9.6", features = ["cookie", "form"] }
axum-macros = "0.4.2"
axum-server = { version = "0.7.1", default-features = false }
bytes = { workspace = true }

View file

@ -9,17 +9,17 @@ pub(crate) enum ProfileMenuItems {
UnixPassword,
}
pub(crate) enum UiMessage {
UnlockEdit,
}
impl std::fmt::Display for UiMessage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UiMessage::UnlockEdit => write!(f, "Unlock Edit 🔒"),
}
}
}
// pub(crate) enum UiMessage {
// UnlockEdit,
// }
//
// impl std::fmt::Display for UiMessage {
// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// match self {
// UiMessage::UnlockEdit => write!(f, "Unlock Edit 🔒"),
// }
// }
// }
pub(crate) enum Urls {
Apps,

View file

@ -47,6 +47,7 @@ pub fn view_router() -> Router<ServerState> {
.route("/reset", get(reset::view_reset_get))
.route("/update_credentials", get(reset::view_self_reset_get))
.route("/profile", get(profile::view_profile_get))
.route("/profile/diff", get(profile::view_profile_get))
.route("/profile/unlock", get(profile::view_profile_unlock_get))
.route("/logout", get(login::view_logout_get))
.route("/oauth2", get(oauth2::view_index_get));
@ -120,6 +121,18 @@ pub fn view_router() -> Router<ServerState> {
)
.route("/api/cu_cancel", post(reset::cancel_cred_update))
.route("/api/cu_commit", post(reset::commit))
.route(
"/api/user_settings/add_email",
post(profile::view_new_email_entry_partial),
)
.route(
"/api/user_settings/edit_profile",
post(profile::view_profile_diff_start_save_post),
)
.route(
"/api/user_settings/confirm_profile",
post(profile::view_profile_diff_confirm_save_post),
)
.layer(HxRequestGuardLayer::new("/ui"));
Router::new().merge(unguarded_router).merge(guarded_router)

View file

@ -1,18 +1,34 @@
use super::constants::{ProfileMenuItems, Urls};
use super::errors::HtmxError;
use super::login::{LoginDisplayCtx, Reauth, ReauthPurpose};
use super::navbar::NavbarCtx;
use crate::https::errors::WebError;
use crate::https::extractors::{DomainInfo, VerifiedClientInformation};
use crate::https::middleware::KOpId;
use crate::https::ServerState;
use askama::Template;
use askama_axum::IntoResponse;
use axum::extract::State;
use axum::http::Uri;
use axum::response::Response;
use axum::Extension;
use axum_extra::extract::cookie::CookieJar;
use axum_extra::extract::Form;
use axum_htmx::{HxEvent, HxPushUrl, HxResponseTrigger};
use futures_util::TryFutureExt;
use kanidm_proto::attribute::Attribute;
use kanidm_proto::constants::{ATTR_DISPLAYNAME, ATTR_LEGALNAME, ATTR_MAIL};
use kanidm_proto::internal::UserAuthToken;
use super::constants::{ProfileMenuItems, UiMessage, Urls};
use super::errors::HtmxError;
use super::login::{LoginDisplayCtx, Reauth, ReauthPurpose};
use super::navbar::NavbarCtx;
use kanidm_proto::v1::Entry;
use kanidmd_lib::filter::{f_eq, f_id, Filter};
use kanidmd_lib::prelude::f_and;
use kanidmd_lib::prelude::PartialValue;
use kanidmd_lib::prelude::FC;
use serde::Deserialize;
use serde::Serialize;
use std::fmt;
use std::fmt::Display;
use std::fmt::Formatter;
#[derive(Template)]
#[template(path = "user_settings.html")]
@ -26,9 +42,43 @@ pub(crate) struct ProfileView {
struct ProfilePartialView {
menu_active_item: ProfileMenuItems,
can_rw: bool,
attrs: ProfileAttributes
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct ProfileAttributes {
account_name: String,
display_name: String,
email: Option<String>,
legal_name: String,
#[serde(rename = "emails[]")]
emails: Vec<String>,
primary_email: Option<String>,
}
#[derive(Template, Clone)]
#[template(path = "user_settings/profile_changes_partial.html")]
struct ProfileChangesPartialView {
menu_active_item: ProfileMenuItems,
can_rw: bool,
attrs: ProfileAttributes,
new_attrs: ProfileAttributes,
}
#[derive(Template, Clone)]
#[template(path = "user_settings/form_modifiable_entry_modifiable_list_partial.html")]
// Modifiable entry in a modifiable list partial
pub(crate) struct FormModEntryModListPartial {
can_rw: bool,
r#type: String,
name: String,
value: String,
invalid_feedback: String,
}
impl Display for ProfileAttributes {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{self:?}")
}
}
pub(crate) async fn view_profile_get(
@ -39,9 +89,23 @@ pub(crate) async fn view_profile_get(
) -> Result<ProfileView, WebError> {
let uat: UserAuthToken = state
.qe_r_ref
.handle_whoami_uat(client_auth_info, kopid.eventid)
.handle_whoami_uat(client_auth_info.clone(), kopid.eventid)
.await?;
let filter = filter_all!(f_and!([f_eq(
Attribute::Uuid,
PartialValue::Uuid(uat.uuid)
)]));
let base: Vec<Entry> = state
.qe_r_ref
.handle_internalsearch(client_auth_info.clone(), filter, None, kopid.eventid)
.await?;
let self_entry = base.first().expect("Self no longer exists");
let empty = vec![];
let emails = self_entry.attrs.get(ATTR_MAIL).unwrap_or(&empty).clone();
let primary_email = emails.first().cloned();
let time = time::OffsetDateTime::now_utc() + time::Duration::new(60, 0);
let can_rw = uat.purpose_readwrite_active(time);
@ -51,13 +115,185 @@ pub(crate) async fn view_profile_get(
profile_partial: ProfilePartialView {
menu_active_item: ProfileMenuItems::UserProfile,
can_rw,
account_name: uat.name().to_string(),
display_name: uat.displayname.clone(),
email: uat.mail_primary.clone(),
attrs: ProfileAttributes {
account_name: uat.name().to_string(),
display_name: uat.displayname.clone(),
legal_name: "hardcoded".to_string(),
emails,
primary_email,
},
},
})
}
pub(crate) async fn view_profile_diff_start_save_post(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
DomainInfo(domain_info): DomainInfo,
// Form must be the last parameter because it consumes the request body
Form(new_attrs): Form<ProfileAttributes>,
) -> axum::response::Result<Response> {
let uat: UserAuthToken = state
.qe_r_ref
.handle_whoami_uat(client_auth_info.clone(), kopid.eventid)
.map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
.await?;
let time = time::OffsetDateTime::now_utc() + time::Duration::new(60, 0);
let can_rw = uat.purpose_readwrite_active(time);
let filter = filter_all!(f_and!([f_eq(
Attribute::Uuid,
PartialValue::Uuid(uat.uuid)
)]));
let base: Vec<Entry> = state
.qe_r_ref
.handle_internalsearch(client_auth_info.clone(), filter, None, kopid.eventid)
.map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info))
.await?;
let self_entry = base.first().expect("Self no longer exists");
let empty = vec![];
let emails = self_entry.attrs.get(ATTR_MAIL).unwrap_or(&empty).clone();
let primary_email = emails.first().cloned();
let profile_view = ProfileChangesPartialView {
menu_active_item: ProfileMenuItems::UserProfile,
can_rw,
attrs: ProfileAttributes {
account_name: uat.name().to_string(),
display_name: uat.displayname.clone(),
legal_name: "hardcoded".to_string(),
emails,
primary_email,
},
new_attrs
};
Ok((
HxPushUrl(Uri::from_static("/ui/profile/diff")),
profile_view,
)
.into_response())
}
pub(crate) async fn view_profile_diff_confirm_save_post(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
DomainInfo(domain_info): DomainInfo,
// Form must be the last parameter because it consumes the request body
Form(new_attrs): Form<ProfileAttributes>,
) -> axum::response::Result<Response> {
let uat: UserAuthToken = state
.qe_r_ref
.handle_whoami_uat(client_auth_info.clone(), kopid.eventid)
.map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
.await?;
dbg!(&new_attrs);
let filter = filter_all!(f_and!([f_id(uat.uuid.to_string().as_str())]));
state
.qe_w_ref
.handle_setattribute(
client_auth_info.clone(),
uat.uuid.to_string(),
ATTR_LEGALNAME.to_string(),
vec![new_attrs.legal_name],
filter.clone(),
kopid.eventid,
)
.map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
.await?;
state
.qe_w_ref
.handle_setattribute(
client_auth_info.clone(),
uat.uuid.to_string(),
ATTR_DISPLAYNAME.to_string(),
vec![new_attrs.display_name],
filter.clone(),
kopid.eventid,
)
.map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
.await?;
state
.qe_w_ref
.handle_setattribute(
client_auth_info.clone(),
uat.uuid.to_string(),
ATTR_MAIL.to_string(),
new_attrs.emails,
filter.clone(),
kopid.eventid,
)
.map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
.await?;
// TODO: These are normally not permitted, user should be prevented from changing non modifiable fields in the UI though
// state
// .qe_w_ref
// .handle_setattribute(
// client_auth_info.clone(),
// uat.uuid.to_string(),
// ATTR_EMAIL.to_string(),
// vec![new_attrs.email.unwrap_or("".to_string())],
// filter.clone(),
// kopid.eventid,
// )
// .map_err(|op_err| HtmxError::new(&kopid, op_err))
// .await?;
//
// state
// .qe_w_ref
// .handle_setattribute(
// client_auth_info.clone(),
// uat.uuid.to_string(),
// ATTR_NAME.to_string(),
// vec![new_attrs.account_name],
// filter.clone(),
// kopid.eventid,
// )
// .map_err(|op_err| HtmxError::new(&kopid, op_err))
// .await?;
// TODO: Calling this here returns the old attributes
match view_profile_get(
State(state),
Extension(kopid),
VerifiedClientInformation(client_auth_info),
DomainInfo(domain_info)
).await {
Ok(pv) => Ok(pv.into_response()),
Err(e) => Ok(e.into_response()),
}
}
// Sends the user a new email input to fill in :)
pub(crate) async fn view_new_email_entry_partial(
State(_state): State<ServerState>,
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
Extension(_kopid): Extension<KOpId>,
) -> axum::response::Result<Response> {
let passkey_init_trigger =
HxResponseTrigger::after_swap([HxEvent::new("addEmailSwapped".to_string())]);
Ok((
passkey_init_trigger,
FormModEntryModListPartial {
can_rw: true,
r#type: "email".to_string(),
name: "emails[]".to_string(),
value: "".to_string(),
invalid_feedback: "Please enter a valid email address.".to_string(),
},
)
.into_response())
}
pub(crate) async fn view_profile_unlock_get(
State(state): State<ServerState>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,

25
server/core/static/external/forms.js vendored Normal file
View file

@ -0,0 +1,25 @@
// This file will contain js helpers to have some interactivity on forms that we can't achieve with pure html.
function rehook_string_list_removers() {
const buttons = document.getElementsByClassName("kanidm-remove-list-entry");
for (let i = 0; i < buttons.length; i++) {
const button = buttons.item(i)
if (button.getAttribute("kanidm_hooked") !== null) return
button.addEventListener("click", (e) => {
// Expected html nesting: li > div.input-group > button.kanidm-remove-list-entry
let li = button.parentElement?.parentElement;
if (li && li.tagName === "LI") {
li.remove();
}
})
button.setAttribute("kanidm_hooked", "")
}
}
window.onload = function () {
rehook_string_list_removers();
document.body.addEventListener("addEmailSwapped", () => {
rehook_string_list_removers();
})
};

View file

@ -0,0 +1,34 @@
htmx.defineExtension("bs-validation", {
onEvent: function (name, evt) {
let form = evt.detail.elt;
// Htmx propagates attributes onto children like button, which would break those buttons, so we return if not a form.
if (form.tagName !== "FORM") return;
// check if trigger attribute and submit event exists
// for the form
if (!form.hasAttribute("hx-trigger")) {
// set trigger for custom event bs-send
form.setAttribute("hx-trigger", "bs-send");
// and attach the event only once
form.addEventListener("submit", function (event) {
if (form.checkValidity()) {
// trigger custom event hx-trigger="bs-send"
htmx.trigger(form, "bsSend");
}
// focus the first :invalid field
let invalidField = form.querySelector(":invalid");
if (invalidField) {
invalidField.focus();
}
console.log("prevented htmx send, form was invalid")
event.preventDefault()
event.stopPropagation()
form.classList.add("was-validated")
}, false)
}
}
});

View file

@ -20,6 +20,16 @@ body {
max-width: 680px;
}
/*
* Bootstrap 5.3 fix for input-group validation
* :has checks that a child can be selected with the selector
* + selects the next sibling.
*/
.was-validated .input-group:has(.form-control:invalid) + .invalid-feedback {
display: block !important;
}
/*
* Sidebar
*/

View file

@ -3,6 +3,8 @@
(% block title %)Profile(% endblock %)
(% block head %)
<script src="/pkg/external/forms.js"></script>
<script src="/pkg/external/htmx_bs_validation.js"></script>
(% endblock %)
(% block main %)

View file

@ -0,0 +1,7 @@
<li class="mb-2">
<div class="input-group">
<input type="(( type ))" class="form-control" name="(( name ))" value="(( value ))" hx-validate="true" required>
(% if can_rw %)<button type="button" class="btn btn-secondary kanidm-remove-list-entry">Remove</button>(% endif %)
</div>
<div class="invalid-feedback">(( invalid_feedback ))</div>
</li>

View file

@ -0,0 +1,89 @@
(% extends "user_settings_partial_base.html" %)
(% block selected_setting_group %)
Profile Difference
(% endblock %)
(% block settings_vertical_point %)lg(% endblock %)
(% block settings_window %)
<form>
<input type="hidden" name="account_name" value="(( new_attrs.account_name ))"/>
<input type="hidden" name="display_name" value="(( new_attrs.display_name ))"/>
<input type="hidden" name="legal_name" value="(( new_attrs.legal_name ))"/>
(% for email in new_attrs.emails %)
<input type="hidden" name="emails[]" value="(( email ))"/>
(% endfor %)
<table class="table table-bordered table-responsive">
<thead>
<tr>
<th scope="col">Attribute</th>
<th scope="col">Old value</th>
<th scope="col">New value</th>
</tr>
</thead>
(% if attrs.account_name != new_attrs.account_name %)
<tr>
<th scope="row">User name</th>
<td class="text-break">(( attrs.account_name ))</td>
<td class="text-break">(( new_attrs.account_name ))</td>
</tr>
(% endif %)
(% if attrs.display_name != new_attrs.display_name %)
<tr>
<th scope="row">Display name</th>
<td class="text-break">(( attrs.display_name ))</td>
<td class="text-break">(( new_attrs.display_name ))</td>
</tr>
(% endif %)
(% if attrs.legal_name != new_attrs.legal_name %)
<tr>
<th scope="row">Legal name</th>
<td class="text-break">(( attrs.legal_name ))</td>
<td class="text-break">(( new_attrs.legal_name ))</td>
</tr>
(% endif %)
(% if attrs.emails != new_attrs.emails %)
<!-- TODO: List new items with +, same items with . -->
<tr>
<th scope="row">Emails</th>
<td class="text-break">
<ul>
(% for email in attrs.emails %)
<li>(( email ))</li>
(% endfor %)
</ul>
</td>
<td class="text-break">
<ul>
(% for email in new_attrs.emails %)
<li>(( email ))</li>
(% endfor %)
</ul>
</td>
</tr>
(% endif %)
</table>
<!-- Edit button -->
<div class="pt-4" hx-target="#user_settings_container" hx-swap="outerHTML">
(% if can_rw %)
<button class="btn btn-danger" type="button" hx-get="/ui/profile" hx-target="#user_settings_container" hx-swap="outerHTML">Discard Changes</button>
<button class="btn btn-primary" type="button" hx-post="/ui/api/user_settings/confirm_profile" hx-target="#user_settings_container" hx-swap="outerHTML">Confirm Changes</button>
(% else %)
<a href="/ui/profile/unlock" hx-boost="false">
<!-- TODO: at the moment, session expiring here means progress is lost. Do we just show an error screen ? We can't pass the update state through the reauth session, and we don't have profile update sessions like cred update. -->
<button class="btn btn-primary" type="button">Unlock Confirm 🔓</button>
</a>
(% endif %)
</div>
</form>
(% endblock %)

View file

@ -6,38 +6,56 @@ Profile
(% block settings_window %)
<form>
<form class="needs-validation" hx-post="/ui/api/user_settings/edit_profile" hx-target="#user_settings_container" hx-swap="outerHTML" hx-validate="true" hx-ext="bs-validation" novalidate>
<div class="mb-2 row">
<label for="profileUserName" class="col-12 col-md-3 col-xl-2 col-form-label">User name</label>
<label for="profileUserName" class="col-12 col-md-3 col-lg-2 col-form-label">User name</label>
<div class="col-12 col-md-6 col-lg-5">
<input type="text" readonly class="form-control-plaintext" id="profileUserName" value="(( account_name ))">
</div>
</div>
<div class="mb-2 row">
<label for="profileDisplayName" class="col-12 col-md-3 col-xl-2 col-form-label">Display name</label>
<div class="col-12 col-md-6 col-lg-5">
<input type="text" class="form-control-plaintext" id="profileDisplayName" value="(( display_name ))" disabled>
<input type="text" readonly class="form-control-plaintext" id="profileUserName" value="(( attrs.account_name ))">
</div>
</div>
<div class="mb-2 row">
<label for="profileEmail" class="col-12 col-md-3 col-xl-2 col-form-label">Email</label>
<label for="profileDisplayName" class="col-12 col-md-3 col-lg-2 col-form-label">Display name</label>
<div class="col-12 col-md-6 col-lg-5">
<input type="email" disabled class="form-control-plaintext" id="profileEmail" value="(( email.clone().unwrap_or("None configured".to_string())))">
<input type="text" class="form-control-plaintext" id="profileDisplayName" value="(( attrs.display_name ))">
</div>
</div>
<div class="mb-2 row">
<label for="profileLegalName" class="col-12 col-md-3 col-lg-2 col-form-label">Legal name</label>
<div class="col-12 col-md-6 col-lg-5">
<input type="text" class="form-control-plaintext" id="profileLegalName" value="(( attrs.legal_name ))">
</div>
</div>
<div class="mb-2">
<label for="profileEmail" class="mb-2">Emails</label>
<div>
<div class="row">
<ul class="col-12 col-md-11 col-lg-8" id="emailList">
(% for email in attrs.emails %)
(% let type = "email" %)
(% let name = "emails[]" %)
(% let value = email %)
(% let invalid_feedback = "Please enter a valid email address." %)
(% include "user_settings/form_modifiable_entry_modifiable_list_partial.html" %)
(% endfor %)
</ul>
</div>
(% if can_rw %)<button type="button" class="btn btn-primary" hx-post="/ui/api/user_settings/add_email" hx-target="#emailList" hx-swap="beforeend">Add Email</button>(% endif %)
</div>
</div>
<!-- Edit button -->
<!-- <div class="pt-4">
<div class="pt-4">
(% if can_rw %)
<button class="btn btn-primary" type="button" hx-post="/ui/api/user_settings/edit_profile" disabled>Edit (Currently Not Working!)</button>
<button class="btn btn-primary" type="submit">Edit</button>
(% else %)
<a href=(Urls::ProfileUnlock) hx-boost="false">
<button class="btn btn-primary" type="button">((UiMessage::UnlockEdit))</button>
<a href="/ui/profile/unlock" hx-boost="false">
<button class="btn btn-primary" type="button">Unlock Edit 🔒</button>
</a>
(% endif %)
</div> -->
</div>
</form>
(% endblock %)

View file

@ -208,6 +208,16 @@ pub static ref SCHEMA_ATTR_DENIED_NAME: SchemaAttribute = SchemaAttribute {
..Default::default()
};
pub static ref SCHEMA_ATTR_DENIED_NAME_DL10: SchemaAttribute = SchemaAttribute {
uuid: UUID_SCHEMA_ATTR_DENIED_NAME,
name: Attribute::DeniedName,
description: "Iname values that are not allowed to be used in 'name'.".to_string(),
syntax: SyntaxType::Utf8StringIname,
multivalue: true,
..Default::default()
};
pub static ref SCHEMA_ATTR_DOMAIN_TOKEN_KEY: SchemaAttribute = SchemaAttribute {
uuid: UUID_SCHEMA_ATTR_DOMAIN_TOKEN_KEY,
name: Attribute::DomainTokenKey,

View file

@ -10,7 +10,7 @@ impl Plugin for ValueDeny {
"plugin_value_deny"
}
#[instrument(level = "debug", name = "base_pre_create_transform", skip_all)]
#[instrument(level = "debug", name = "denied_names_pre_create_transform", skip_all)]
#[allow(clippy::cognitive_complexity)]
fn pre_create_transform(
qs: &mut QueryServerWriteTransaction,
@ -19,9 +19,25 @@ impl Plugin for ValueDeny {
) -> Result<(), OperationError> {
let denied_names = qs.denied_names();
if denied_names.is_empty() {
// Nothing to check.
return Ok(());
}
let mut pass = true;
for entry in cand {
// If the entry doesn't have a uuid, it's invalid anyway and will fail schema.
if let Some(e_uuid) = entry.get_uuid() {
// SAFETY - Thanks to JpWarren blowing his nipper clean off, we need to
// assert that the break glass and system accounts are NOT subject to
// this process.
if e_uuid < DYNAMIC_RANGE_MINIMUM_UUID {
// These entries are exempt
continue;
}
}
if let Some(name) = entry.get_ava_single_iname(Attribute::Name) {
if denied_names.contains(name) {
pass = false;
@ -37,27 +53,24 @@ impl Plugin for ValueDeny {
}
}
#[instrument(level = "debug", name = "base_pre_modify", skip_all)]
fn pre_modify(
qs: &mut QueryServerWriteTransaction,
_pre_cand: &[Arc<EntrySealedCommitted>],
pre_cand: &[Arc<EntrySealedCommitted>],
cand: &mut Vec<Entry<EntryInvalid, EntryCommitted>>,
_me: &ModifyEvent,
) -> Result<(), OperationError> {
Self::modify(qs, cand)
Self::modify(qs, pre_cand, cand)
}
#[instrument(level = "debug", name = "base_pre_modify", skip_all)]
fn pre_batch_modify(
qs: &mut QueryServerWriteTransaction,
_pre_cand: &[Arc<EntrySealedCommitted>],
pre_cand: &[Arc<EntrySealedCommitted>],
cand: &mut Vec<Entry<EntryInvalid, EntryCommitted>>,
_me: &BatchModifyEvent,
) -> Result<(), OperationError> {
Self::modify(qs, cand)
Self::modify(qs, pre_cand, cand)
}
#[instrument(level = "debug", name = "base::verify", skip_all)]
fn verify(qs: &mut QueryServerReadTransaction) -> Vec<Result<(), ConsistencyError>> {
let denied_names = qs.denied_names().clone();
@ -68,7 +81,15 @@ impl Plugin for ValueDeny {
match qs.internal_search(filt) {
Ok(entries) => {
for entry in entries {
results.push(Err(ConsistencyError::DeniedName(entry.get_uuid())));
let e_uuid = entry.get_uuid();
// SAFETY - Thanks to JpWarren blowing his nipper clean off, we need to
// assert that the break glass accounts are NOT subject to this process.
if e_uuid < DYNAMIC_RANGE_MINIMUM_UUID {
// These entries are exempt
continue;
}
results.push(Err(ConsistencyError::DeniedName(e_uuid)));
}
}
Err(err) => {
@ -83,17 +104,37 @@ impl Plugin for ValueDeny {
}
impl ValueDeny {
#[instrument(level = "debug", name = "denied_names_modify", skip_all)]
fn modify(
qs: &mut QueryServerWriteTransaction,
cand: &mut Vec<Entry<EntryInvalid, EntryCommitted>>,
pre_cand: &[Arc<EntrySealedCommitted>],
cand: &mut [EntryInvalidCommitted],
) -> Result<(), OperationError> {
let denied_names = qs.denied_names();
if denied_names.is_empty() {
// Nothing to check.
return Ok(());
}
let mut pass = true;
for entry in cand {
if let Some(name) = entry.get_ava_single_iname(Attribute::Name) {
if denied_names.contains(name) {
for (pre_entry, post_entry) in pre_cand.iter().zip(cand.iter()) {
// If the entry doesn't have a uuid, it's invalid anyway and will fail schema.
let e_uuid = pre_entry.get_uuid();
// SAFETY - Thanks to JpWarren blowing his nipper clean off, we need to
// assert that the break glass accounts are NOT subject to this process.
if e_uuid < DYNAMIC_RANGE_MINIMUM_UUID {
// These entries are exempt
continue;
}
let pre_name = pre_entry.get_ava_single_iname(Attribute::Name);
let post_name = post_entry.get_ava_single_iname(Attribute::Name);
if let Some(name) = post_name {
// Only if the name is changing, and is denied.
if pre_name != post_name && denied_names.contains(name) {
pass = false;
error!(?name, "name denied by system configuration");
}
@ -117,10 +158,10 @@ mod tests {
let me_inv_m = ModifyEvent::new_internal_invalid(
filter!(f_eq(Attribute::Uuid, PVUUID_SYSTEM_CONFIG.clone())),
ModifyList::new_list(vec![Modify::Present(
Attribute::DeniedName,
Value::new_iname("tobias"),
)]),
ModifyList::new_list(vec![
Modify::Present(Attribute::DeniedName, Value::new_iname("tobias")),
Modify::Present(Attribute::DeniedName, Value::new_iname("ellie")),
]),
);
assert!(server_txn.modify(&me_inv_m).is_ok());
@ -148,30 +189,103 @@ mod tests {
#[qs_test]
async fn test_valuedeny_modify(server: &QueryServer) {
setup_name_deny(server).await;
// Create an entry that has a name which will become denied to test how it
// interacts.
let mut server_txn = server.write(duration_from_epoch_now()).await.unwrap();
let t_uuid = Uuid::new_v4();
let e_uuid = Uuid::new_v4();
assert!(server_txn
.internal_create(vec![entry_init!(
(Attribute::Class, EntryClass::Object.to_value()),
(Attribute::Class, EntryClass::Account.to_value()),
(Attribute::Class, EntryClass::Person.to_value()),
(Attribute::Name, Value::new_iname("newname")),
(Attribute::Uuid, Value::Uuid(t_uuid)),
(Attribute::Description, Value::new_utf8s("Tobias")),
(Attribute::DisplayName, Value::new_utf8s("Tobias"))
(Attribute::Name, Value::new_iname("ellie")),
(Attribute::Uuid, Value::Uuid(e_uuid)),
(Attribute::Description, Value::new_utf8s("Ellie Meow")),
(Attribute::DisplayName, Value::new_utf8s("Ellie Meow"))
),])
.is_ok());
// Now mod it
assert!(server_txn.commit().is_ok());
setup_name_deny(server).await;
let mut server_txn = server.write(duration_from_epoch_now()).await.unwrap();
// Attempt to mod ellie.
// Can mod a different attribute
assert!(server_txn
.internal_modify_uuid(
t_uuid,
e_uuid,
&ModifyList::new_purge_and_set(Attribute::DisplayName, Value::new_utf8s("tobias"))
)
.is_ok());
// Can't mod to another invalid name.
assert!(server_txn
.internal_modify_uuid(
e_uuid,
&ModifyList::new_purge_and_set(Attribute::Name, Value::new_iname("tobias"))
)
.is_err());
// Can mod to a valid name.
assert!(server_txn
.internal_modify_uuid(
e_uuid,
&ModifyList::new_purge_and_set(
Attribute::Name,
Value::new_iname("miss_meowington")
)
)
.is_ok());
// Now mod from the valid name to an invalid one.
assert!(server_txn
.internal_modify_uuid(
e_uuid,
&ModifyList::new_purge_and_set(Attribute::Name, Value::new_iname("tobias"))
)
.is_err());
assert!(server_txn.commit().is_ok());
}
#[qs_test]
async fn test_valuedeny_jpwarren_special(server: &QueryServer) {
// Assert that our break glass accounts are exempt from this processing.
let mut server_txn = server.write(duration_from_epoch_now()).await.unwrap();
let me_inv_m = ModifyEvent::new_internal_invalid(
filter!(f_eq(Attribute::Uuid, PVUUID_SYSTEM_CONFIG.clone())),
ModifyList::new_list(vec![
Modify::Present(Attribute::DeniedName, Value::new_iname("admin")),
Modify::Present(Attribute::DeniedName, Value::new_iname("idm_admin")),
]),
);
assert!(server_txn.modify(&me_inv_m).is_ok());
assert!(server_txn.commit().is_ok());
let mut server_txn = server.write(duration_from_epoch_now()).await.unwrap();
assert!(server_txn
.internal_modify_uuid(
UUID_IDM_ADMIN,
&ModifyList::new_purge_and_set(
Attribute::DisplayName,
Value::new_utf8s("Idm Admin")
)
)
.is_ok());
assert!(server_txn
.internal_modify_uuid(
UUID_ADMIN,
&ModifyList::new_purge_and_set(Attribute::DisplayName, Value::new_utf8s("Admin"))
)
.is_ok());
assert!(server_txn.commit().is_ok());
}
#[qs_test]

View file

@ -427,7 +427,10 @@ impl QueryServerWriteTransaction<'_> {
// =========== Apply changes ==============
// Now update schema
let idm_schema_changes = [SCHEMA_CLASS_DOMAIN_INFO_DL10.clone().into()];
let idm_schema_changes = [
SCHEMA_ATTR_DENIED_NAME_DL10.clone().into(),
SCHEMA_CLASS_DOMAIN_INFO_DL10.clone().into(),
];
idm_schema_changes
.into_iter()