Skip to content
Merged
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,25 @@ commands, `--json` output and exit codes. Each release's notes start with its se
for each stable release: the release's own binary on a distroless base, running as a non-root
user, tagged with the exact version only. It is signed by digest with Sigstore and carries the
release's SBOM as a signed attestation. ([#36](https://github.com/Hodeitek/hodeishield-cli/issues/36))
- Several client tenants with one portfolio key. `hodeishield tenants list` lists the client
tenants the key may read. The key is created by an organisation administrator of an active MSSP
organisation and goes in `HODEISHIELD_API_KEY`. The global `--tenant <slug|id>` selects one client
for any read command, including `get` and `compliance posture`, and prints the line
`Client tenant: <slug> (<id>)` on standard error. `--all-tenants` reads every client on a `list` command and adds
`tenant_id`, `tenant_slug` and `tenant_name` to each row in `--json` and `--csv` (a `TENANT`
column in the table). An unknown client fails with exit code 5, before any data is read. Without
these options nothing changes. `--all-tenants` asks one client at a time and skips clients that
are inactive or whose licence is not in force, with a note on standard error; the command still
exits 0 when only inactive clients are skipped. A client whose licence status the CLI does not
know is not read and is named on standard error. A client that fails when it is read because its
licence is not in force (exit code 8) or it is not found (exit code 5) is also named and skipped.
In both cases the other clients' data is printed and the command then exits non-zero (with the
code of the first client that failed, or 1 for an unknown licence status), so a script never takes
a partial result for a complete one. Any other failure stops the run before anything is printed,
for example exit code 6 (naming the limit) when a rate limit is exhausted. Against an API without
portfolio access the commands fail with exit code 7. The README has a new "Several tenants (MSSP)"
section. ([#54](https://github.com/Hodeitek/hodeishield-cli/issues/54),
[#45](https://github.com/Hodeitek/hodeishield-cli/issues/45))

### Changed (scripting surface)

Expand Down Expand Up @@ -64,6 +83,14 @@ commands, `--json` output and exit codes. Each release's notes start with its se
details, secure use, updating, removal and where the SBOM is published
([#127](https://github.com/Hodeitek/hodeishield-cli/issues/127),
[#128](https://github.com/Hodeitek/hodeishield-cli/issues/128)).
- Errors that involve a client tenant now say so and what to do. An unknown client tenant (404
`TENANT_NOT_FOUND`) points to `hodeishield tenants list`; a portfolio key rejected for a missing
scope points to an organisation administrator; a rate limit names whether it is the key's, the
organisation's or the client tenant's own, and says whether other clients are affected; a
credential that covers several clients without `--tenant` or `--all-tenants` is told to pick one;
and `--tenant` with a credential for one tenant only is told to leave it out. Messages and
hints go to standard error; exit codes do not change. ([#54](https://github.com/Hodeitek/hodeishield-cli/issues/54),
[#45](https://github.com/Hodeitek/hodeishield-cli/issues/45))

### Fixed

Expand Down
104 changes: 102 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ hodeishield logout # revokes the token at the app and removes it from

You sign in to [app.hodeishield.com](https://app.hodeishield.com) as usual (2FA, passkey or SSO), and
on the consent screen you choose **the one tenant** the CLI may read. A sign-in token never covers
more than that tenant, and it only reads. To use another tenant, define another profile and sign in
more than that tenant, and it only reads. To use an unrelated tenant, define another profile and sign in
to it:

```sh
Expand All @@ -305,6 +305,9 @@ A profile that has not been defined is refused (`Profile ... is not defined`).
`login` first checks that the system keychain is available and stops before opening the browser or
showing a code if it is not; use a tenant API key on such a machine.

An MSSP reads its clients with one credential instead: see
[Several tenants (MSSP)](#several-tenants-mssp).

With `--device`, open the address the CLI prints on any device and **type the code by hand**: the app
does not accept a link with the code filled in, on purpose.

Expand Down Expand Up @@ -350,6 +353,103 @@ rest. `--all` stops with an error past 100,000 items (or 1,000 pages); narrow th
or walk it with `--page`. Every list takes `--sort` and `--order`; `--help` on any command lists
what it accepts.

### Several tenants (MSSP)

An MSSP reads all of its client tenants with **one** credential: a **portfolio key**, created in the
app by an organisation administrator of an active MSSP organisation, in the MSSP area (API keys).
Nobody else can create one: not a tenant administrator, and not a platform operator acting outside
an MSSP organisation. You do not need
a profile, a key or a sign-in per client, and you should not set them up that way.

A portfolio key reads only, always expires (90 days by default, 365 at most), and works on the
HodeiShield API only. It can be limited when it is created to some of your clients and to source IP
ranges; a request from another address is refused like an unknown key (exit code 3). Signing in
with `hodeishield login` does not cover a whole portfolio: use the key. Rotate your portfolio keys
before they expire, so scripts do not stop working; a new key and its predecessor both work for up
to 7 days while the old one is in force.

**1. Use the credential as any other key.**

```sh
export HODEISHIELD_API_KEY=hsp_3f2a9c0d1b4e_... # the portfolio key; keep it in your CI's secret store
```

The key starts with `hsp_`, a 12-character identifier and `_`, then a long secret. The first 16
characters identify the key in the app and in audit entries; the CLI never prints any part of the
key. The same variable takes a tenant API key as before.

**2. List the client tenants it may read.**

```console
$ hodeishield tenants list
SLUG NAME ORGANISATION TYPE STATUS LICENCE ID
acme Acme S.L. Acme Group client active in_force 11111111-1111-4111-8111-111111111111
beta Beta Corp Beta Group client active grace 22222222-2222-4222-8222-222222222222
```

It takes `--json`, `--csv`, `--page` and `--all` like any list, sorted by name. It includes your
own organisation's tenants (`TYPE` `own` for the internal one) and clients that are inactive or have
no licence in force, which the `STATUS` and `LICENCE` columns tell apart. A key that was limited to
some tenants when it was created lists and reads only those. The list is read from the API each
time: a client added to or removed from your portfolio shows up on the next command.

**3. Select the client a read command reads.** The selection goes with the command and is never
remembered, so a later command cannot read another client by surprise.

```sh
hodeishield --tenant acme vendors list # one client, by slug or id
hodeishield --all-tenants alerts list --status open
```

- `--tenant <slug|id>` works on every read command (`list`, `get`, `compliance posture`). The CLI
prints `Client tenant: acme (<id>)` on standard error, so a log shows which client was read;
standard output is exactly what it is without a selection.
- `--all-tenants` works on `list` commands and `compliance controls`. It fetches every page of each
client (`--per-page` applies, `--page` does not) and adds the tenant to each row: a `TENANT`
column in the table, and `tenant_id`, `tenant_slug` and `tenant_name` fields in `--json` and
`--csv`. Clients that are inactive, or whose licence status is `inactive`, are skipped with a
note on standard error. A client in its grace period is read. A client whose licence status this
CLI does not know is not read either; it is named on standard error (`licence status … is not
known to this version; not read`) and the command exits non-zero, so nothing goes missing
silently. Clients are asked one after the other. In the table, a slug with characters other
than `a-z`, `0-9` and `-` is followed by the start of the client's id, so look-alike names can be
told apart; `--json` and `--csv` carry the full values.
- The tenant on a row is the one whose own request returned it. The CLI asks the API about one
client at a time, and refuses an answer that does not confirm that client.

```sh
hodeishield --all-tenants alerts list --status open --json \
| jq -r '.[] | [.tenant_slug, .severity, .title] | @tsv'
hodeishield --all-tenants vendors list --criticality critical --csv > critical-vendors.csv
```

**4. When something is not readable.**

- A client that is not in your portfolio (or that you mistyped) fails with `No client tenant … in
this credential's portfolio.` and exit code 5, before anything is read.
- With `--all-tenants`, a client that cannot be read on its own account (for example its licence is
not in force, exit code 8) is reported on standard error and skipped; the command prints the
others and still exits non-zero, so a script does not mistake a partial result for a complete
one. This is the only signal of a partial result: check the exit code, and read standard error
for which clients were left out. A client named by both an id and a slug of another client
(a slug can look like an id) is refused as ambiguous; use the id shown by `tenants list`.
- Without `--tenant` or `--all-tenants`, a portfolio key is refused by the API, and the CLI tells
you to choose. A single-tenant key or sign-in behaves exactly as before; asking it for a client
fails with exit code 4 (it lacks the portfolio scope).
- A client you named that is deactivated, or whose licence is not in force, fails with exit code 8.
- A portfolio key is limited to 100 requests per minute, and all the portfolio keys of your
organisation together to 600 (the API may change these numbers). The CLI waits out a short
`Retry-After` and retries; if the wait is longer, or the limit is still exhausted, the command
stops with exit code 6 and says which limit it hit (`portfolio_key`, `portfolio_org`,
`portfolio_tenant` or `tenant`), instead of asking the next client. Use `--per-page 200` and
narrow with filters to need fewer requests.
- Against an API that does not offer portfolio access yet, `tenants list`, `--tenant` and
`--all-tenants` fail with `This API does not offer portfolio access yet.` and exit code 7. Nothing
else changes.

**5. Revoke access.** Revoke the portfolio key in the app, in the MSSP area; it stops working on its
next request. A client leaving your portfolio is dropped from the list on the next command.

### JSON for scripts

`--json` prints the body the API returned, unchanged, including fields newer than your CLI. With
Expand Down Expand Up @@ -389,7 +489,7 @@ hodeishield vendors list --all --csv > vendors.csv
| 4 | The credential lacks the scope the command needs |
| 5 | Not found (or not in your organisation) |
| 6 | Rate limit exhausted (the CLI already retried short waits) |
| 7 | The API could not be reached or failed |
| 7 | The API could not be reached or failed, or does not offer portfolio access yet |
| 8 | The tenant's licence is not in force: a tenant administrator must renew or reactivate it |

Errors go to stderr with a hint and, when the API gave one, a **request id**: quote it when you
Expand Down
44 changes: 34 additions & 10 deletions crates/hodeishield-api/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ pub struct Operation {
/// open: the server may accept a value that is not listed, so use this to help and to warn,
/// never to refuse. Read it through [`Operation::known_values`].
pub known_values: &'static [(&'static str, &'static [&'static str])],
/// Whether a client built for one client tenant selects that tenant on this operation and
/// checks the answer's confirmation. True for the data operations, false for the portfolio
/// listing, which is never scoped.
pub tenant_scoped: bool,
}

impl Operation {
Expand Down Expand Up @@ -134,12 +138,7 @@ fn tenant_header(headers: &HeaderMap) -> Option<String> {
if values.next().is_some() {
return None;
}
let canonical = value.len() == 36
&& value.bytes().enumerate().all(|(i, b)| match i {
8 | 13 | 18 | 23 => b == b'-',
_ => b.is_ascii_hexdigit(),
});
canonical.then(|| value.to_ascii_lowercase())
crate::portfolio::is_uuid(value).then(|| value.to_ascii_lowercase())
}

/// What the client reports about each request it sends, for `--verbose`. It carries no header and
Expand Down Expand Up @@ -175,6 +174,15 @@ pub struct RetryEvent<'a> {
type Observer = Arc<dyn Fn(&RequestEvent<'_>) + Send + Sync>;
type RetryObserver = Arc<dyn Fn(&RetryEvent<'_>) + Send + Sync>;
type Renew = Arc<dyn Fn() -> Option<SecretString> + Send + Sync>;
type AnswerCheck = Arc<dyn Fn(&HeaderMap) -> Result<(), Error> + Send + Sync>;

/// Extra headers a client sends on data requests, and the check its successful answers must pass.
/// The portfolio module fills it in; this file knows nothing about what the headers mean.
#[derive(Clone, Default)]
pub(crate) struct Scope {
pub(crate) headers: HeaderMap,
pub(crate) check: Option<AnswerCheck>,
}

/// Builds a [`Client`].
pub struct ClientBuilder {
Expand All @@ -189,6 +197,7 @@ pub struct ClientBuilder {
observer: Option<Observer>,
retry_observer: Option<RetryObserver>,
renew: Option<Renew>,
pub(crate) scope: Scope,
}

impl fmt::Debug for ClientBuilder {
Expand Down Expand Up @@ -308,6 +317,7 @@ impl ClientBuilder {
observer: self.observer,
retry_observer: self.retry_observer,
renew: self.renew,
scope: self.scope,
})
}
}
Expand All @@ -325,6 +335,7 @@ pub struct Client {
observer: Option<Observer>,
retry_observer: Option<RetryObserver>,
renew: Option<Renew>,
scope: Scope,
}

impl fmt::Debug for Client {
Expand Down Expand Up @@ -361,6 +372,7 @@ impl Client {
observer: None,
retry_observer: None,
renew: None,
scope: Scope::default(),
}
}

Expand Down Expand Up @@ -409,24 +421,29 @@ impl Client {
std::thread::sleep(wait);
}

/// A GET. The operation decides whether the client's tenant selection applies: scope headers
/// are sent and checked only when `operation.tenant_scoped`.
pub(crate) fn get<T: DeserializeOwned>(
&self,
operation: &'static Operation,
path: String,
query: Vec<(&'static str, String)>,
) -> Result<ApiResponse<T>, Error> {
let scoped = operation.tenant_scoped;
let url = self.url(&path, &query);
let mut rate_limit_retries = 0;
let mut transient_retries = 0;
let mut renewed = false;
loop {
let started = Instant::now();
let response = match self
let mut request = self
.http
.get(url.clone())
.header(AUTHORIZATION, self.authorization()?)
.send()
{
.header(AUTHORIZATION, self.authorization()?);
if scoped {
request = request.headers(self.scope.headers.clone());
}
let response = match request.send() {
Ok(response) => response,
Err(e)
if (e.is_connect() || e.is_timeout())
Expand Down Expand Up @@ -455,8 +472,14 @@ impl Client {
elapsed: started.elapsed(),
});
}
// A scoped answer that does not carry the scope that was asked for is never decoded.
let verdict = match (&self.scope.check, scoped, status.is_success()) {
(Some(check), true, true) => check(response.headers()),
_ => Ok(()),
};
let body = read_body(response, MAX_RESPONSE_BYTES).map_err(Error::Body)?;
if status.is_success() {
verdict?;
let raw: serde_json::Value =
serde_json::from_slice(&body).map_err(|source| Error::Decode {
operation: operation.id,
Expand Down Expand Up @@ -637,6 +660,7 @@ mod tests {
summary: "List things",
scopes: &[],
known_values: &[("severity", &["critical", "high"])],
tenant_scoped: true,
};
assert_eq!(
operation.known_values("severity"),
Expand Down
21 changes: 21 additions & 0 deletions crates/hodeishield-api/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,27 @@ pub enum Error {
/// An id or slug that would change which resource the path addresses.
#[error("{0:?} is not a valid identifier")]
InvalidPathArgument(String),
/// The API has no portfolio listing (it answered `404` or `405` for the route).
#[error("this API does not offer portfolio access (HTTP {status} for the tenant listing)")]
PortfolioNotOffered {
/// The status the route answered with.
status: u16,
},
/// A client tenant identifier that is not a UUID.
#[error("{0:?} is not a valid client tenant identifier")]
InvalidTenant(String),
/// The API answered for another client tenant than the one asked for, or did not say which.
#[error(
"the API did not confirm the client tenant that was asked for (asked for {requested}, \
answer says {})",
answered.as_deref().unwrap_or("nothing")
)]
TenantMismatch {
/// The tenant the request named.
requested: String,
/// What the answer's header said, when it said anything.
answered: Option<String>,
},
}

impl Error {
Expand Down
Loading
Loading