Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ release promotes it to a version heading; the `bump-version` skill does that.

## Development

## 0.4.1 -- 2026-08-11

### Added

- `admin.public` serves the whole admin API unauthenticated; off by default.

### Fixed

- `admin.groups` without `admin` left the write actions no legal value; it now
means public.

## 0.4.0 -- 2026-08-11

### Added
Expand Down
14 changes: 7 additions & 7 deletions 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
Expand Up @@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "3"

[workspace.package]
version = "0.4.0"
version = "0.4.1"
edition = "2024"
rust-version = "1.94"
license = "Apache-2.0"
Expand Down
69 changes: 69 additions & 0 deletions crates/doppel-admin/src/access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,22 @@ pub fn authorize(
}
}

/// So a public configuration can be answered with a borrow like any other.
const PUBLIC: Subjects = Subjects::Public;

fn effective_subjects<'a>(
admin: &'a AdminConfig,
proxy: Option<&'a ProxyConfig>,
action: Action,
) -> &'a Subjects {
// `public: true`, or `groups: []`, makes every action public -- including a
// proxy's overrides, which is why this comes before them. A per-proxy
// `read: admin` under a public configuration would otherwise be the one
// thing still asking for a token, which is not what "public" was set for.
if admin.is_public() {
return &PUBLIC;
}

if action.overridable()
&& let Some(overrides) = proxy.and_then(|p| p.access.as_ref())
{
Expand Down Expand Up @@ -417,6 +428,64 @@ proxies:
);
}

/// `public: true` answers every action publicly, and the fixture's `access`
/// is deliberately restrictive so this cannot pass by the defaults being
/// permissive already. Enumerated over every action rather than spot-checked:
/// the point of the flag is that nothing is left needing a token.
#[test]
fn a_public_admin_api_authorises_every_action_for_anyone() {
let config = load_from_str(&CONFIG.replacen(" access:", " public: true\n access:", 1))
.expect("fixture must parse");
for action in [
Action::List,
Action::Read,
Action::Create,
Action::Update,
Action::Delete,
Action::Upload,
] {
assert!(
authorize(&config.admin, None, action, &Caller::Anonymous).is_ok(),
"`{}` must be public",
action.as_str()
);
}
}

/// `groups: []` names nobody, so it means the same thing. Worth its own test
/// because it is the spelling an operator reaches by trying to lock the
/// configuration down, which is the opposite of what it does.
#[test]
fn an_empty_groups_list_authorises_every_action_too() {
let config = load_from_str(&CONFIG.replacen(" access:", " groups: []\n access:", 1))
.expect("fixture must parse");
assert!(config.admin.is_public());
assert!(authorize(&config.admin, None, Action::Delete, &Caller::Anonymous).is_ok());
}

/// A per-proxy override cannot claw back a token requirement under a public
/// configuration -- it would be the one thing still asking for a token, which
/// is not what the flag was set for.
#[test]
fn a_proxy_override_does_not_survive_a_public_admin_api() {
let config = load_from_str(&CONFIG.replacen(" access:", " public: true\n access:", 1))
.expect("fixture must parse");
let locked = config
.proxies
.iter()
.find(|proxy| proxy.access.is_some())
.expect("the fixture must define a proxy with overrides");
assert!(
authorize(
&config.admin,
Some(locked),
Action::Read,
&Caller::Anonymous
)
.is_ok()
);
}

#[test]
fn the_configured_header_name_is_the_one_read() {
let c = config();
Expand Down
67 changes: 59 additions & 8 deletions crates/doppel-core/src/config/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,18 +121,40 @@ pub struct AdminConfig {
/// each unique. `DOPPEL_ADMIN_TOKENS` can supply these instead.
#[serde(default)]
pub tokens: Vec<TokenConfig>,
/// Serve the whole admin API unauthenticated.
///
/// `false` by default. `true` makes every action `public` and leaves no name
/// to reference, so `groups` is effectively empty and `access` effectively
/// all-public -- whatever either of them says. Anything they did say is
/// reported as a startup advisory rather than refused, so a configuration
/// being made temporarily public does not have to be gutted first and
/// rebuilt afterwards.
///
/// This overrides rule V34, which otherwise refuses a public write action.
/// V34 exists so that an unauthenticated writable proxy set cannot happen by
/// omission; a field named `public` set to `true` is not an omission. Think
/// of it as the flag V34 was holding the line for.
///
/// An `Option` and skipped when absent, for the reason given on `groups`:
/// adding it must not change the canonical YAML of configurations written
/// before it existed. Read it through [`AdminConfig::is_public`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public: Option<bool>,
/// Which names `access` may reference, here and in a proxy's overrides.
///
/// `["*"]`, the default, allows any. A concrete list allows exactly those:
/// `["admin", "ci"]` permits `admin` and `ci` and refuses `user`.
///
/// `[]` allows none, and that is stronger than it looks: every action
/// defaults to the `admin` group, so an empty list makes the defaults
/// themselves violations and every one of the six actions has to be written
/// out as `public`.
/// `[]` names nobody, which leaves no action anything to reference -- so it
/// means the same thing as `public: true`, and is treated as it. It used to
/// mean a configuration with no legal value for `create`, `update`, `delete`
/// or `upload`: V36 refused their `admin` default and V34 refused `public`,
/// so the document could not be written at all.
///
/// `public` is never governed by this. It is the absence of a subject rather
/// than a name, so an allow-list has nothing to say about it.
/// `public` and `admin` are never governed by this. `public` is the absence
/// of a subject rather than a name; `admin` is the fallback every action
/// already has, and a list that revoked it would produce the same
/// unsatisfiable state as `[]` once did.
///
/// Checked by rule V36, not by this type: it compares one field against
/// another, which is what is left for the rule set once the types have taken
Expand All @@ -159,12 +181,41 @@ impl AdminConfig {
/// default.
///
/// One place resolves it, so no caller has to remember that absent and
/// `["*"]` mean the same thing while `[]` means the opposite -- which is
/// exactly the confusion an `Option<Vec<_>>` invites.
/// `["*"]` mean the same thing while `[]` means something else entirely --
/// which is exactly the confusion an `Option<Vec<_>>` invites.
#[must_use]
pub fn allowed_groups(&self) -> &[AllowedGroup] {
self.groups.as_deref().unwrap_or(&ANY_GROUP)
}

/// Whether the admin API is served unauthenticated.
///
/// True for `public: true`, and equally for `groups: []`: a list naming
/// nobody leaves every action nothing to reference, so all-public is the only
/// reading of it that describes a configuration that can run.
///
/// Everything that consults `access` goes through here, so "public" cannot
/// mean one thing to the rule set and another to the code that authorises a
/// request -- which is the failure this being a derived value rather than a
/// rewritten document is meant to prevent.
#[must_use]
pub fn is_public(&self) -> bool {
self.public.unwrap_or(false) || self.groups.as_deref().is_some_and(<[_]>::is_empty)
}

/// What `access` amounts to once `is_public` is taken into account.
///
/// `None` when the configuration is public, which every caller reads as
/// "`Subjects::Public` for every action". Returning the borrowed `access` in
/// the ordinary case keeps the common path allocation-free.
#[must_use]
pub fn effective_access(&self) -> Option<&AccessConfig> {
if self.is_public() {
None
} else {
Some(&self.access)
}
}
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
Expand Down
Loading