Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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>
    }
}