Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/forge/hive_token.rs
atlas 6ba29c4d33 swarm-controller: re-read the forge token from the store when the forge rejects it
The controller read its forge token from the swarm secret store once, when
its client slot filled, and kept sending it until the daemon restarted. A
token the forge re-minted or revoked left every forge-backed path failing
with 401 until someone restarted the controller, and a restart drops the
runtime-only job graph.

Every forge request now goes through `forge::api::Api::call`. On a 401 it
asks a `StoredSecret` (the queue clients' debounced, single-flight re-read
with backoff) for a token to try instead, rebuilds the forgejo client with
it, and sends the request once more. A second 401, or a store still holding
the refused token, surfaces the error as before. Only 401 counts: unlike an
IdP, the forge answers 400 to a malformed request, and a re-read started by
one would put a real 401 that follows inside the backoff.

forgejo-api bakes the token into its own reqwest client's default headers
and takes no client or middleware from outside, so there is no single
request function to hook. Each `self.api.<endpoint>(..)` site becomes
`self.api.call(|api| api.<endpoint>(..).into_future())`; option structs a
site moved into the call are cloned, since the closure can run twice. The
`OnceLock<Arc<Client>>` slot and its `get()` callers are unchanged.

Refs #5017
2026-10-14 10:16:18 +02:00

698 lines
26 KiB
Rust

//! Each hive's read-only account on the swarm's forge, `hive-<hive>`: created
//! here with the admin API, put in the [`HIVES_TEAM`] of `agent-configs` (read
//! on every config repo, including ones created later), and given one token,
//! stored at `swarm/hives/<hive>/forge/token`, where hive-c0re reads it under
//! the hive's own store identity.
//!
//! The roster is the controller's hive directory, as for
//! `matrix_account::hive_sender`. The token decision is
//! [`super::agent_token`]'s, under this module's name and scopes, so a stored
//! token the forge still lists with the same last eight characters and scopes
//! is kept, and a pass whose reads fail queues nothing.
//!
//! A new agent may not be called `hive-<x>`
//! (`hive_types::reserved_agent_prefix_violation`, checked at agent creation),
//! which is the primary guard against an agent claiming a hive's account. An
//! agent created before that rule can still hold such a name, so each side
//! also refuses an account carrying the other's email: neither ever locks
//! down, joins the team with, or mints a token for the other's account.
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use forgejo_api::structs::{
AccessToken, CreateTeamOption, CreateTeamOptionPermission, EditTeamOption,
EditTeamOptionPermission, RepoSearchQuery, Team, TeamPermission,
};
use forgejo_api::{Auth, Forgejo};
use swarm_secret_client::forge;
use super::agent_token::{Decision, Observed, classify_named, is_not_found};
use super::{CONFIG_ORG, Client, folds_into_success};
/// The forge's name for every hive token this module mints.
pub const HIVE_TOKEN_NAME: &str = "swarm-hive";
/// What a hive token may do: read repositories. Which repositories is the
/// account's business, and the account is only in [`HIVES_TEAM`].
pub const HIVE_TOKEN_SCOPES: &str = "read:repository";
/// The team in `agent-configs` every hive account is a member of.
const HIVES_TEAM: &str = "hives";
const HIVES_TEAM_DESCRIPTION: &str = "hyperhive hives — read on every agent config repo";
/// What a hive reads in a config repo: the git tree, and which PR merged a
/// commit.
const HIVES_TEAM_UNITS: [&str; 2] = ["repo.code", "repo.pulls"];
/// How often [`spawn`] re-checks every hive.
const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
/// The email this module creates `hive`'s account with, and the one it
/// requires before acting on an account by that name.
///
/// Under its own domain, so it can equal neither email an agent account
/// carries (`super::agent_email`, `super::legacy_agent_email`) whatever the
/// agent is called.
fn hive_email(hive: &str) -> String {
format!("{}@hives.hyperhive.local", forge::hive_login(hive))
}
/// Whether the account named [`forge::hive_login`]`(hive)`, carrying `email`,
/// is the one this module creates.
fn is_hive_account(hive: &str, email: Option<&str>) -> bool {
email == Some(hive_email(hive).as_str())
}
/// Whether `t` has every setting [`Client::ensure_hives_team`] gives it.
fn hives_team_matches(t: &Team) -> bool {
let units_match = t.units.as_ref().is_some_and(|units| {
let mut have: Vec<&str> = units.iter().map(String::as_str).collect();
let mut want = HIVES_TEAM_UNITS.to_vec();
have.sort_unstable();
want.sort_unstable();
have == want
});
units_match
&& t.permission == Some(TeamPermission::Read)
&& t.includes_all_repositories == Some(true)
&& t.can_create_org_repo == Some(false)
&& t.description.as_deref() == Some(HIVES_TEAM_DESCRIPTION)
}
/// [`HIVES_TEAM`] as one pass found it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TeamSeen {
/// Present with every setting [`hives_team_matches`] checks.
Matches(i64),
/// Present with some other setting, which may be a wider grant.
Drifted(i64),
Absent,
/// The read failed.
Unknown,
}
/// What one pass found for one hive.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HiveSeen {
pub token: Observed,
/// Whether the account is in [`HIVES_TEAM`]; `None` when that read failed
/// or the team id is not known.
pub member: Option<bool>,
}
/// The hives a pass queues a `MintHiveForgeToken` job for.
///
/// A token that has to be minted, or an account that does not exist, always
/// queues. A kept token queues only when the account or the team is not as
/// the job leaves them; that job finds the token current again and does not
/// rotate it.
///
/// [`Observed::Unknown`] never queues: a forge or store outage must not turn
/// into a rotation of every hive's token. Neither does a
/// [`Decision::Conflict`], which the job would refuse anyway.
pub fn plan(team: TeamSeen, hives: &[(String, HiveSeen)]) -> Vec<String> {
let team_needs_work = matches!(team, TeamSeen::Absent | TeamSeen::Drifted(_));
hives
.iter()
.filter(|(_, seen)| match seen.token {
Observed::Unknown | Observed::Decided(Decision::Conflict) => false,
Observed::NoForgeUser | Observed::Decided(Decision::Mint(_)) => true,
Observed::Decided(Decision::Keep) => team_needs_work || seen.member == Some(false),
})
.map(|(hive, _)| hive.clone())
.collect()
}
impl Client {
/// Make sure `hive`'s account exists, is in [`HIVES_TEAM`], and holds a
/// live [`HIVE_TOKEN_NAME`] token in the store, minting one when it does
/// not. The whole job of the `MintHiveForgeToken` node.
///
/// A rotation is [`Client::replace_token`]'s delete then create. The new
/// token is used once, and the forge's and the store's state is read back
/// and classified again, so the node does not report success on a write
/// nobody has read.
///
/// # Errors
/// When the store or the forge refuses a step, when an account by the
/// hive's name exists and is not the hive's, or when the read-back does
/// not show the new token.
pub async fn ensure_hive_forge_token(&self, hive: &str) -> Result<()> {
let login = forge::hive_login(hive);
let path = forge::hive_token_path(hive)?;
self.ensure_hive_user(hive, &login).await?;
let team = self.ensure_hives_team().await?;
self.api
.call(|api| api.org_add_team_member(team, &login).into_future())
.await
.with_context(|| format!("adding {login} to {CONFIG_ORG}/{HIVES_TEAM}"))?;
let store = crate::store::connect()
.await
.context("logging in to the swarm secret store")?;
let (stored, listed) = self.read_hive_token(&store, &login, &path).await?;
let reason =
match classify_named(HIVE_TOKEN_NAME, HIVE_TOKEN_SCOPES, stored.as_ref(), &listed) {
Decision::Keep => {
tracing::debug!(hive, "hive forge token is current; left as it is");
return Ok(());
}
Decision::Conflict => bail!(
"the forge lists more than one {HIVE_TOKEN_NAME:?} token for {login:?}; \
delete them by id and re-run"
),
Decision::Mint(reason) => reason,
};
let credential = self
.replace_token(
&store,
&login,
HIVE_TOKEN_NAME,
HIVE_TOKEN_SCOPES,
&listed,
&path,
)
.await?;
Forgejo::new(Auth::Token(&credential.value), self.url.clone())
.context("building a forge client with the new token")?
.repo_search(RepoSearchQuery::default())
.await
.with_context(|| format!("using {login}'s new forge token"))?;
let (stored, listed) = self.read_hive_token(&store, &login, &path).await?;
let now = classify_named(HIVE_TOKEN_NAME, HIVE_TOKEN_SCOPES, stored.as_ref(), &listed);
if now != Decision::Keep {
bail!("{login}'s new forge token does not read back as current: {now:?}");
}
tracing::info!(hive, ?reason, %path, "hive forge token minted and stored");
Ok(())
}
/// The stored token at `path` and every token the forge lists for
/// `login`, which must exist.
async fn read_hive_token(
&self,
store: &swarm_secret_client::SecretStore,
login: &str,
path: &str,
) -> Result<(Option<forge::Credential>, Vec<AccessToken>)> {
let stored = store
.read_optional(path)
.await
.with_context(|| format!("reading {path}"))?;
let Some(listed) = self.list_agent_tokens(login).await? else {
bail!("the forge has no user {login:?} after this job created it");
};
Ok((stored, listed))
}
/// Create `hive`'s account if there is none, refuse one that is not the
/// hive's, and lock it out of creating repos.
async fn ensure_hive_user(&self, hive: &str, login: &str) -> Result<()> {
self.create_user(login, &hive_email(hive)).await?;
let user = self
.api
.call(|api| api.user_get(login).into_future())
.await
.with_context(|| format!("read forge user {login}"))?;
if !is_hive_account(hive, user.email.as_deref()) {
bail!(
"forge account `{login}` exists but is not hive `{hive}`'s (email {:?}, expected \
`{}`); refusing to give it the hive's access",
user.email,
hive_email(hive)
);
}
self.api
.call(|api| {
api.admin_edit_user(login, super::repo_creation_lockdown(login))
.into_future()
})
.await
.with_context(|| format!("disable repo creation for forge user {login}"))?;
Ok(())
}
/// The id of [`HIVES_TEAM`] in `agent-configs`, creating it or
/// correcting its settings first when needed. Its members are not
/// touched here.
pub(super) async fn ensure_hives_team(&self) -> Result<i64> {
match self.observe_hives_team().await {
TeamSeen::Matches(id) => Ok(id),
TeamSeen::Drifted(id) => {
self.api
.call(|api| {
api.org_edit_team(
id,
EditTeamOption {
can_create_org_repo: Some(false),
description: Some(HIVES_TEAM_DESCRIPTION.to_owned()),
includes_all_repositories: Some(true),
name: HIVES_TEAM.to_owned(),
permission: Some(EditTeamOptionPermission::Read),
units: Some(HIVES_TEAM_UNITS.map(str::to_owned).to_vec()),
units_map: None,
},
)
.into_future()
})
.await
.with_context(|| {
format!("reconcile {CONFIG_ORG}/{HIVES_TEAM} team settings")
})?;
tracing::info!("swarm forge: reconciled {CONFIG_ORG}/{HIVES_TEAM} team settings");
Ok(id)
}
TeamSeen::Absent => self.create_hives_team().await,
TeamSeen::Unknown => bail!("could not read the teams of {CONFIG_ORG}"),
}
}
async fn create_hives_team(&self) -> Result<i64> {
let team = CreateTeamOption {
can_create_org_repo: Some(false),
description: Some(HIVES_TEAM_DESCRIPTION.to_owned()),
includes_all_repositories: Some(true),
name: HIVES_TEAM.to_owned(),
permission: Some(CreateTeamOptionPermission::Read),
units: Some(HIVES_TEAM_UNITS.map(str::to_owned).to_vec()),
units_map: None,
};
match self
.api
.call(|api| api.org_create_team(CONFIG_ORG, team.clone()).into_future())
.await
{
Ok(created) => {
tracing::info!("swarm forge: created {CONFIG_ORG}/{HIVES_TEAM} team");
created
.id
.context("the forge created the hives team but returned no id")
}
// A duplicate is a 409 or a 422; either counts once a re-read
// finds the team, which also supplies its id.
Err(e) => match self.observe_hives_team().await {
TeamSeen::Matches(id) | TeamSeen::Drifted(id) if folds_into_success(&e, true) => {
Ok(id)
}
_ => Err(e).with_context(|| format!("create team {CONFIG_ORG}/{HIVES_TEAM}")),
},
}
}
pub(super) async fn observe_hives_team(&self) -> TeamSeen {
match self
.api
.call(|api| api.org_list_teams(CONFIG_ORG).into_future())
.await
{
Ok((_, teams)) => teams
.into_iter()
.find(|t| t.name.as_deref() == Some(HIVES_TEAM))
.map_or(TeamSeen::Absent, |t| match t.id {
Some(id) if hives_team_matches(&t) => TeamSeen::Matches(id),
Some(id) => TeamSeen::Drifted(id),
None => TeamSeen::Unknown,
}),
Err(e) => {
tracing::warn!(error = %e, "hive forge tokens: reading the teams of {CONFIG_ORG} failed");
TeamSeen::Unknown
}
}
}
/// What the store, the forge and the team say about `hive`.
async fn observe_hive(
&self,
store: &swarm_secret_client::SecretStore,
team: TeamSeen,
hive: &str,
) -> HiveSeen {
let login = forge::hive_login(hive);
let stored = match forge::hive_token_path(hive) {
Ok(path) => store.read_optional::<forge::Credential>(&path).await,
Err(e) => Err(e),
};
let token = match stored {
Err(e) => {
tracing::warn!(hive, error = %e, "hive forge token: store read failed");
Observed::Unknown
}
Ok(stored) => match self.list_agent_tokens(&login).await {
Ok(Some(listed)) => Observed::Decided(classify_named(
HIVE_TOKEN_NAME,
HIVE_TOKEN_SCOPES,
stored.as_ref(),
&listed,
)),
Ok(None) => Observed::NoForgeUser,
Err(e) => {
tracing::warn!(hive, error = %format!("{e:#}"), "hive forge token: forge read failed");
Observed::Unknown
}
},
};
let member = match team {
TeamSeen::Matches(id) | TeamSeen::Drifted(id) => {
match self
.api
.call(|api| api.org_list_team_member(id, &login).into_future())
.await
{
Ok(_) => Some(true),
Err(e) if is_not_found(&e) => Some(false),
Err(e) => {
tracing::warn!(hive, error = %e, "hive forge token: team membership read failed");
None
}
}
}
TeamSeen::Absent => Some(false),
TeamSeen::Unknown => None,
};
HiveSeen { token, member }
}
/// One pass: the team, then every hive in `hives`.
async fn observe_hives(&self, hives: &[String]) -> Result<(TeamSeen, Vec<(String, HiveSeen)>)> {
let store = crate::store::connect()
.await
.context("logging in to the swarm secret store")?;
let team = self.observe_hives_team().await;
let mut observed = Vec::with_capacity(hives.len());
for hive in hives {
observed.push((hive.clone(), self.observe_hive(&store, team, hive).await));
}
Ok((team, observed))
}
}
/// Check every hive now and every [`RECONCILE_INTERVAL`] after, and hand the
/// hives that need a job to `enqueue`, which inserts a `MintHiveForgeToken`
/// node for each.
///
/// The roster is the controller's hive directory. A pass that fails is
/// logged and retried on the next tick; it never stops the daemon.
pub fn spawn(
client: Arc<Client>,
hives: Vec<String>,
enqueue: impl Fn(Vec<String>) + Send + 'static,
) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
loop {
ticker.tick().await;
let Some(outcome) =
super::within_forge_deadline("hive forge tokens", client.observe_hives(&hives))
.await
else {
continue;
};
match outcome {
Ok((team, observed)) => {
let queued = plan(team, &observed);
if queued.is_empty() {
tracing::debug!(checked = observed.len(), "hive forge tokens: all current");
} else {
tracing::info!(
checked = observed.len(),
queued = queued.len(),
"hive forge tokens: queueing jobs"
);
enqueue(queued);
}
}
Err(e) => tracing::warn!(
error = %format!("{e:#}"),
retry_in_s = RECONCILE_INTERVAL.as_secs(),
"hive forge tokens: pass failed; retrying next tick"
),
}
}
});
}
#[cfg(test)]
mod tests {
use super::super::agent_token::MintReason;
use super::*;
const VALUE: &str = "0123456789abcdef0123456789abcdef01234567";
fn stored() -> forge::Credential {
forge::Credential {
value: VALUE.to_owned(),
name: HIVE_TOKEN_NAME.to_owned(),
}
}
fn token(name: &str, last_eight: &str, scopes: &[&str]) -> AccessToken {
AccessToken {
created_at: None,
id: Some(1),
name: Some(name.to_owned()),
repositories: None,
scopes: Some(scopes.iter().map(|s| (*s).to_owned()).collect()),
sha1: None,
token_last_eight: Some(last_eight.to_owned()),
}
}
fn decide(stored: Option<&forge::Credential>, listed: &[AccessToken]) -> Decision {
classify_named(HIVE_TOKEN_NAME, HIVE_TOKEN_SCOPES, stored, listed)
}
fn seen(token: Observed, member: Option<bool>) -> HiveSeen {
HiveSeen { token, member }
}
#[test]
fn the_token_reads_repositories_and_nothing_else() {
assert_eq!(HIVE_TOKEN_SCOPES, "read:repository");
}
#[test]
fn nothing_on_the_forge_or_in_the_store_creates() {
assert_eq!(decide(None, &[]), Decision::Mint(MintReason::NotOnForge));
let ours = token(HIVE_TOKEN_NAME, "01234567", &["read:repository"]);
assert_eq!(decide(None, &[ours]), Decision::Mint(MintReason::NotStored));
}
#[test]
fn a_current_token_is_kept() {
let ours = token(HIVE_TOKEN_NAME, "01234567", &["read:repository"]);
assert_eq!(decide(Some(&stored()), &[ours]), Decision::Keep);
}
#[test]
fn another_value_or_wider_scopes_rotate() {
let other = token(HIVE_TOKEN_NAME, "ffffffff", &["read:repository"]);
assert_eq!(
decide(Some(&stored()), &[other]),
Decision::Mint(MintReason::LastEightMismatch)
);
let wider = token(HIVE_TOKEN_NAME, "01234567", &["write:repository"]);
assert_eq!(
decide(Some(&stored()), &[wider]),
Decision::Mint(MintReason::ScopeMismatch)
);
}
#[test]
fn an_agent_token_on_the_account_is_not_the_hive_token() {
// The two names must differ, or a hive account would be judged by the
// agent token's scopes and the other way round.
assert_ne!(HIVE_TOKEN_NAME, super::super::agent_token::AGENT_TOKEN_NAME);
let agents = token(
super::super::agent_token::AGENT_TOKEN_NAME,
"01234567",
&["read:repository"],
);
assert_eq!(
decide(Some(&stored()), &[agents]),
Decision::Mint(MintReason::NotOnForge)
);
}
#[test]
fn create_and_rotate_are_queued_and_keep_is_not() {
let hives = [
(
"keep".to_owned(),
seen(Observed::Decided(Decision::Keep), Some(true)),
),
(
"rotate".to_owned(),
seen(
Observed::Decided(Decision::Mint(MintReason::LastEightMismatch)),
Some(true),
),
),
(
"create".to_owned(),
seen(Observed::NoForgeUser, Some(false)),
),
(
"conflict".to_owned(),
seen(Observed::Decided(Decision::Conflict), Some(true)),
),
];
assert_eq!(plan(TeamSeen::Matches(7), &hives), ["rotate", "create"]);
}
#[test]
fn an_unknown_hive_is_never_queued() {
// Not even when the team is missing, which queues every hive whose
// token is known.
let hives = [
("a".to_owned(), seen(Observed::Unknown, Some(false))),
("b".to_owned(), seen(Observed::Unknown, None)),
];
for team in [
TeamSeen::Matches(7),
TeamSeen::Drifted(7),
TeamSeen::Absent,
TeamSeen::Unknown,
] {
assert!(plan(team, &hives).is_empty(), "{team:?}");
}
}
#[test]
fn a_kept_token_outside_the_team_is_queued() {
let keep = |member| seen(Observed::Decided(Decision::Keep), member);
let hives = [
("in".to_owned(), keep(Some(true))),
("out".to_owned(), keep(Some(false))),
("unread".to_owned(), keep(None)),
];
assert_eq!(plan(TeamSeen::Matches(7), &hives), ["out"]);
assert_eq!(plan(TeamSeen::Unknown, &hives), ["out"]);
// A missing or drifted team queues every known hive: the job fixes the
// team, and finds each token current.
assert_eq!(plan(TeamSeen::Absent, &hives), ["in", "out", "unread"]);
assert_eq!(plan(TeamSeen::Drifted(7), &hives), ["in", "out", "unread"]);
}
#[test]
fn only_the_hives_own_email_is_its_account() {
assert!(is_hive_account(
"alpha",
Some("hive-alpha@hives.hyperhive.local")
));
assert!(!is_hive_account("alpha", None));
assert!(!is_hive_account(
"beta",
Some("hive-alpha@hives.hyperhive.local")
));
}
#[test]
fn an_agent_called_hive_x_is_not_hive_xs_account() {
// Both ways round: the hive job refuses the agent's account, and the
// agent's own guard (`disable_repo_creation`) refuses the hive's,
// because neither accepts the other's email.
let login = forge::hive_login("alpha");
for agents in [
super::super::agent_email(&login),
super::super::legacy_agent_email(&login),
] {
assert!(!is_hive_account("alpha", Some(&agents)), "{agents}");
assert_ne!(hive_email("alpha"), agents);
}
}
/// The job-side guard, against a stand-in forge: an existing account
/// named `hive-alpha` that carries an agent's email is refused before the
/// lockdown, so nothing after it (team, token) runs either.
#[tokio::test]
async fn the_job_refuses_an_agent_account_named_like_the_hive() {
use super::super::tests::{lockdown_patch, stub_forge};
let (client, seen) = stub_forge(
(409, r#"{"message":"user already exists"}"#),
(200, r#"{"email":"hive-alpha@hyperhive.local"}"#),
(200, "{}"),
)
.await;
let err = client
.ensure_hive_user("alpha", "hive-alpha")
.await
.expect_err("an agent's account is not the hive's");
let msg = format!("{err:#}");
assert!(msg.contains("not hive `alpha`'s"), "{msg}");
assert!(lockdown_patch(&seen, "hive-alpha").is_none());
}
/// The other way round: creating an agent called `hive-alpha` refuses
/// hive `alpha`'s account rather than lock it down as the agent's.
#[tokio::test]
async fn an_agent_create_refuses_the_hives_account() {
use super::super::tests::{lockdown_patch, stub_forge};
let (client, seen) = stub_forge(
(409, r#"{"message":"user already exists"}"#),
(200, r#"{"email":"hive-alpha@hives.hyperhive.local"}"#),
(200, "{}"),
)
.await;
client
.ensure_agent_user("hive-alpha")
.await
.expect_err("the hive's account is not the agent's");
assert!(lockdown_patch(&seen, "hive-alpha").is_none());
}
#[tokio::test]
async fn the_job_locks_down_the_hives_own_account() {
use super::super::tests::{lockdown_patch, stub_forge};
let (client, seen) = stub_forge(
(409, r#"{"message":"user already exists"}"#),
(200, r#"{"email":"hive-alpha@hives.hyperhive.local"}"#),
(200, "{}"),
)
.await;
client
.ensure_hive_user("alpha", "hive-alpha")
.await
.expect("the hive's own account");
let body = lockdown_patch(&seen, "hive-alpha").expect("no lockdown PATCH sent");
assert_eq!(body["max_repo_creation"], 0);
}
fn team(permission: TeamPermission, units: &[&str]) -> Team {
Team {
can_create_org_repo: Some(false),
description: Some(HIVES_TEAM_DESCRIPTION.to_owned()),
id: Some(7),
includes_all_repositories: Some(true),
name: Some(HIVES_TEAM.to_owned()),
organization: None,
permission: Some(permission),
units: Some(units.iter().map(|u| (*u).to_owned()).collect()),
units_map: None,
}
}
#[test]
fn the_team_matches_only_as_a_read_team_on_every_repo() {
assert!(hives_team_matches(&team(
TeamPermission::Read,
&["repo.pulls", "repo.code"]
)));
assert!(!hives_team_matches(&team(
TeamPermission::Write,
&HIVES_TEAM_UNITS
)));
assert!(!hives_team_matches(&team(
TeamPermission::Read,
&["repo.code"]
)));
let mut some_repos = team(TeamPermission::Read, &HIVES_TEAM_UNITS);
some_repos.includes_all_repositories = Some(false);
assert!(!hives_team_matches(&some_repos));
}
}