crates/gunnar-ui-web/src/pages/policy.rs
//! The pre-receive gate, on screen: branch protection, ref-name policy, gates.
//!
//! This replaced a `DisabledPanel` that said "the whole Policy service answers
//! Unimplemented". It did not: gunnar stores the policy document at
//! `refs/gunnar/policy` and serves all six RPCs, and had for some time. Branch
//! protection worked and nothing could reach it — the worst shape a feature can
//! be in, because the console was the thing telling everyone it did not exist.
//!
//! # Three rules this page follows
//!
//! **The double negative is undone here, once.** gunnar's wire field is
//! `allow_force_push`, and `allow_force_push = false` is what a protected
//! branch looks like. Printing that on a row asks an operator to read a
//! negation under pressure, which is exactly when they will read it wrong. Each
//! rule prints what it FORBIDS: `force-push refused`, `delete refused`,
//! `signature required`.
//!
//! **A rule that forbids nothing says so.** All three switches off is a legal,
//! storable rule that gunnar lists back and `ListGates` counts as configured —
//! and it stops nothing. It gets a warn badge, not a blank cell.
//!
//! **Empty is a sentence, not a blank list.** "Nothing is protected" is a fact
//! about the repository. A blank panel is what a failed load looks like too,
//! and those two must never render the same; the load failure goes through
//! [`resolved`] into a `FailBox` instead.
//!
//! # Who sees the buttons
//!
//! `Status::may_write()` — the console's existing viewer/operator/admin ladder,
//! read from the session context, the same source `pulls` and `repos` use.
//! Hiding the controls is a courtesy, not the gate: the gate is `role_floor` on
//! the server, which refuses a viewer's POST, DELETE and PUT here whatever the
//! page renders.
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use crate::api::{segment, Fail};
use crate::pages::resolved;
use crate::ui::{Badge, Empty, FailBox, Hint, Panel, Tone};
/// One branch-protection rule.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Protection {
/// A glob over FULL ref names — `refs/heads/main`, never `main`.
pub ref_pattern: String,
pub allow_force_push: bool,
pub allow_delete: bool,
pub require_signature: bool,
pub allowed_principal_ids: Vec<String>,
pub max_object_bytes: u64,
pub max_objects_per_push: u64,
/// A merge into a matching ref does not count the AUTHOR's own approval.
#[serde(default)]
pub forbid_self_approval: bool,
/// Distinct verified approvers a merge into a matching ref needs; 0 keeps
/// gunnar's baseline of one.
#[serde(default)]
pub required_approvals: u32,
}
/// The ref-name allowlist and denylist. Deny wins.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct RefNames {
pub allow_patterns: Vec<String>,
pub deny_patterns: Vec<String>,
}
/// One gate, and whether this repository has anything configured for it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Gate {
pub name: String,
pub description: String,
pub enabled: bool,
}
/// What a rule FORBIDS, in words an operator does not have to negate.
///
/// The empty vector is meaningful and is rendered as such by the caller: a rule
/// with every switch open forbids nothing at all.
fn forbids(rule: &Protection) -> Vec<String> {
let mut words = Vec::new();
if !rule.allow_force_push {
words.push("force-push refused".to_owned());
}
if !rule.allow_delete {
words.push("delete refused".to_owned());
}
if rule.require_signature {
words.push("signature required".to_owned());
}
if rule.max_object_bytes > 0 {
words.push(format!(
"objects over {} refused",
crate::fmt::bytes(rule.max_object_bytes)
));
}
if rule.max_objects_per_push > 0 {
words.push(format!(
"more than {} objects per push refused",
rule.max_objects_per_push
));
}
if !rule.allowed_principal_ids.is_empty() {
words.push(format!("only {}", rule.allowed_principal_ids.join(", ")));
}
if rule.forbid_self_approval {
words.push("self-approval refused at merge".to_owned());
}
if rule.required_approvals > 0 {
words.push(format!(
"{} distinct approvals required to merge",
rule.required_approvals
));
}
words
}
/// One line of text per pattern, which is how the two policy lists are edited.
fn lines(value: &str) -> Vec<String> {
value
.lines()
.map(|line| line.trim().to_owned())
.filter(|line| !line.is_empty())
.collect()
}
/// Branch protection, the ref-name policy and the gate list for one repository.
#[component]
pub fn PolicyPanels(namespace: Memo<String>, name: Memo<String>) -> impl IntoView {
let reload = RwSignal::new(0u32);
// gunnar's per-repository answer (Admin on it), never the console role.
let rights = crate::pages::repo_rights();
let may_write = Memo::new(move |_| rights.get().may_administer());
let base = move || {
format!(
"/api/repos/{}/{}",
segment(&namespace.get()),
segment(&name.get())
)
};
let rules = LocalResource::new(move || {
reload.track();
let url = format!("{}/protection", base());
async move { crate::api::get::<Vec<Protection>>(&url).await }
});
let gates = LocalResource::new(move || {
reload.track();
let url = format!("{}/gates", base());
async move { crate::api::get::<Vec<Gate>>(&url).await }
});
let names = LocalResource::new(move || {
reload.track();
let url = format!("{}/ref-names", base());
async move { crate::api::get::<RefNames>(&url).await }
});
// The add-rule form. The switches are phrased as what they FORBID, so the
// form and the rows below it read the same way round, and the two that make
// a branch protected start on.
let pattern = RwSignal::new(String::new());
let refuse_force = RwSignal::new(true);
let refuse_delete = RwSignal::new(true);
let need_signature = RwSignal::new(false);
let refuse_self_approval = RwSignal::new(false);
let required_approvals = RwSignal::new(String::new());
let add_fail = RwSignal::new(None::<Fail>);
let busy = RwSignal::new(false);
let add = move |_| {
if busy.get_untracked() {
return;
}
busy.set(true);
add_fail.set(None);
let url = format!("{}/protection", base());
let body = serde_json::json!({
"ref_pattern": pattern.get_untracked(),
// Inverted exactly here, at the wire. Everything the operator reads
// is in the "refused" direction; everything gunnar stores is in the
// "allowed" one.
"allow_force_push": !refuse_force.get_untracked(),
"allow_delete": !refuse_delete.get_untracked(),
"require_signature": need_signature.get_untracked(),
"forbid_self_approval": refuse_self_approval.get_untracked(),
// An unparseable count is 0 — gunnar's baseline of one verified
// approval — never a silent bigger number.
"required_approvals": required_approvals
.get_untracked()
.trim()
.parse::<u32>()
.unwrap_or(0),
});
leptos::task::spawn_local(async move {
match crate::api::post::<_, Protection>(&url, &body).await {
Ok(_) => {
pattern.set(String::new());
reload.update(|n| *n += 1);
}
Err(refusal) => add_fail.set(Some(refusal)),
}
busy.set(false);
});
};
let remove = move |rule: String| {
add_fail.set(None);
// The pattern rides in the query string: it contains slashes, and a
// path segment cannot carry them.
let url = format!("{}/protection?ref_pattern={}", base(), segment(&rule));
leptos::task::spawn_local(async move {
match crate::api::delete::<serde_json::Value>(&url).await {
Ok(_) => reload.update(|n| *n += 1),
Err(refusal) => add_fail.set(Some(refusal)),
}
});
};
// The ref-name policy is one document gunnar replaces wholesale, so it is
// edited as one: two lists, saved together.
let allow_text = RwSignal::new(String::new());
let deny_text = RwSignal::new(String::new());
let loaded_names = RwSignal::new(false);
let names_fail = RwSignal::new(None::<Fail>);
let names_busy = RwSignal::new(false);
// Fill the boxes once, from the server's answer. Once, not on every read:
// re-seeding on a later poll would throw away whatever was being typed.
Effect::new(move |_| {
if loaded_names.get_untracked() {
return;
}
if let Some(Ok(policy)) = names.get() {
allow_text.set(policy.allow_patterns.join("\n"));
deny_text.set(policy.deny_patterns.join("\n"));
loaded_names.set(true);
}
});
let save_names = move |_| {
if names_busy.get_untracked() {
return;
}
names_busy.set(true);
names_fail.set(None);
let url = format!("{}/ref-names", base());
let body = RefNames {
allow_patterns: lines(&allow_text.get_untracked()),
deny_patterns: lines(&deny_text.get_untracked()),
};
leptos::task::spawn_local(async move {
match crate::api::put::<_, RefNames>(&url, &body).await {
Ok(saved) => {
allow_text.set(saved.allow_patterns.join("\n"));
deny_text.set(saved.deny_patterns.join("\n"));
reload.update(|n| *n += 1);
}
Err(refusal) => names_fail.set(Some(refusal)),
}
names_busy.set(false);
});
};
view! {
<Panel title="Branch protection">
<div class="panel-body">
<Hint
line="A refused push never lands. Patterns are globs over FULL ref names: \"refs/heads/main\"."
more="Gunnar runs these between \"pack is indexed\" and \"refs are applied\", so a \
refused push never lands. The pattern is a glob over FULL ref names — \
\"refs/heads/main\", not \"main\"; a bare branch name matches nothing and is \
refused here rather than stored as a guard that is not one."
/>
{move || may_write.get().then(|| view! {
<div class="policy-form">
<div class="field">
<label for="prot-pattern">"Ref pattern"</label>
<input
id="prot-pattern"
type="text"
placeholder="refs/heads/main"
prop:value=move || pattern.get()
on:input=move |ev| pattern.set(event_target_value(&ev))
/>
<span class="hint">"refs/heads/release/* protects a whole series."</span>
</div>
<div class="switches">
<label class="switch">
<input
type="checkbox"
prop:checked=move || refuse_force.get()
on:change=move |ev| refuse_force.set(event_target_checked(&ev))
/>
"Refuse force-push"
</label>
<label class="switch">
<input
type="checkbox"
prop:checked=move || refuse_delete.get()
on:change=move |ev| refuse_delete.set(event_target_checked(&ev))
/>
"Refuse delete"
</label>
<label class="switch">
<input
type="checkbox"
prop:checked=move || need_signature.get()
on:change=move |ev| need_signature.set(event_target_checked(&ev))
/>
"Require signature"
</label>
<label class="switch">
<input
type="checkbox"
prop:checked=move || refuse_self_approval.get()
on:change=move |ev| refuse_self_approval.set(event_target_checked(&ev))
/>
"Refuse self-approval at merge"
</label>
</div>
<div class="field">
<label for="prot-required">"Required approvals (merge)"</label>
<input
id="prot-required"
type="number"
min="0"
placeholder="0"
prop:value=move || required_approvals.get()
on:input=move |ev| required_approvals.set(event_target_value(&ev))
/>
<Hint
line="Distinct verified approvers of the exact tip. 0 keeps the baseline: one."
more="The same principal counts once. 0 keeps Gunnar's baseline: one \
verified approval."
/>
</div>
<button class="btn primary" disabled=move || busy.get() on:click=add>
{move || if busy.get() { "Saving …" } else { "Protect" }}
</button>
</div>
})}
{move || add_fail.get().map(|fail| view! { <FailBox fail=fail/> })}
</div>
{move || resolved(rules.get(), None, move |all: Vec<Protection>| {
if all.is_empty() {
return view! {
<Empty
title="Nothing is protected"
detail="Anyone with write access may force-push or delete any branch or tag."
more="Every branch and tag here accepts a force-push and a delete from \
anyone with write access. That is a fact about this repository, \
not an empty page."
/>
}
.into_any();
}
let writable = may_write.get();
view! {
<div class="table-wrap">
<table>
<caption class="vh">"Branch protection rules"</caption>
<thead>
<tr>
<th scope="col">"Ref pattern"</th>
<th scope="col">"What it forbids"</th>
{writable.then(|| view! { <th scope="col"></th> })}
</tr>
</thead>
<tbody>
{all
.into_iter()
.map(move |rule| {
let words = forbids(&rule);
let this = rule.ref_pattern.clone();
view! {
<tr>
<td class="oid">{rule.ref_pattern.clone()}</td>
<td>
<div class="chips">
{if words.is_empty() {
// A stored rule that stops nothing.
// Loud, because it reads as protection
// in every list that counts it.
view! {
<Badge
tone=Tone::Warn
label="forbids nothing"
/>
}
.into_any()
} else {
words
.into_iter()
.map(|word| view! {
<Badge tone=Tone::Pass label=word/>
})
.collect_view()
.into_any()
}}
</div>
</td>
{writable.then(move || view! {
<td class="num">
<button
class="btn quiet"
on:click=move |_| remove(this.clone())
>
"Remove"
</button>
</td>
})}
</tr>
}
})
.collect_view()}
</tbody>
</table>
</div>
}
.into_any()
})}
</Panel>
<div class="split">
<Panel title="Ref-name policy">
<div class="panel-body">
<p class="truncation">
"One pattern per line, over full ref names. "
<strong>"Deny wins"</strong>
": a name matched by both lists is refused. Both lists empty means every \
name Gunnar itself accepts is allowed."
</p>
{move || resolved(names.get(), None, move |policy: RefNames| {
if !may_write.get() {
let allow = policy.allow_patterns.clone();
let deny = policy.deny_patterns.clone();
return view! {
<PatternList title="Allowed" patterns=allow/>
<PatternList title="Denied" patterns=deny/>
}
.into_any();
}
view! {
<div class="field">
<label for="allow-patterns">"Allowed"</label>
<textarea
id="allow-patterns"
rows="4"
placeholder="refs/heads/*"
prop:value=move || allow_text.get()
on:input=move |ev| allow_text.set(event_target_value(&ev))
></textarea>
</div>
<div class="field">
<label for="deny-patterns">"Denied"</label>
<textarea
id="deny-patterns"
rows="4"
placeholder="refs/heads/wip/*"
prop:value=move || deny_text.get()
on:input=move |ev| deny_text.set(event_target_value(&ev))
></textarea>
</div>
<div class="row">
<button
class="btn"
disabled=move || names_busy.get()
on:click=save_names
>
{move || if names_busy.get() {
"Saving …"
} else {
"Save ref-name policy"
}}
</button>
<span class="hint">
"Saving replaces both lists; emptying them clears the policy."
</span>
</div>
{move || names_fail.get().map(|fail| view! { <FailBox fail=fail/> })}
}
.into_any()
})}
</div>
</Panel>
<Panel title="Gates">
<div class="panel-body">
<Hint
line="Gunnar's v1 gate set. \"configured\": the gate has something to act on here."
more="\"configured\" means this repository has something for that gate to act \
on — a gate with nothing configured cannot refuse anything."
/>
</div>
{move || resolved(gates.get(), None, |all: Vec<Gate>| {
if all.is_empty() {
return view! {
<Empty
title="No gates"
detail="This server reported no gates at all, which is older than \
the v1 gate set."
/>
}
.into_any();
}
view! {
<ul class="gate-list">
{all
.into_iter()
.map(|gate| view! {
<li>
<div class="gate-head">
<code>{gate.name.clone()}</code>
<Badge
tone=if gate.enabled { Tone::Pass } else { Tone::Plain }
label=if gate.enabled {
"configured"
} else {
"nothing configured"
}
/>
</div>
<p>{gate.description.clone()}</p>
</li>
})
.collect_view()}
</ul>
}
.into_any()
})}
</Panel>
</div>
}
}
/// One read-only list of patterns, for a session that may not edit them.
#[component]
fn PatternList(#[prop(into)] title: String, patterns: Vec<String>) -> impl IntoView {
view! {
<div class="field">
<label>{title}</label>
{if patterns.is_empty() {
view! { <span class="unset">"none"</span> }.into_any()
} else {
view! {
<ul class="pattern-list">
{patterns
.into_iter()
.map(|p| view! { <li>{p}</li> })
.collect_view()}
</ul>
}
.into_any()
}}
</div>
}
}