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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,9 @@ your company account and confirm, and Aster stores a read-only token under
out, Aster prints one line (`not signed in; run aster cache login`) and
continues with the local cache. See
[Reading the cache from a laptop](docs/test-tracking.md#reading-the-cache-from-a-laptop).
The same sign-in reads the flaky, broken, cluster and quarantine data the
dashboard shows: `aster srv flaky`, `aster srv clusters`, `aster srv get <path>`
print JSON ([details](docs/test-tracking.md#reading-the-dashboards-data)).

A hit restores declared `outputs` from the artifact cache and skips execution.
`aster cache status` reads that store's per-project index. `--json` prints
Expand Down
31 changes: 26 additions & 5 deletions crates/aster-srv/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ Pages are sent with a strict `Content-Security-Policy`, `nosniff`,
| `ASTER_SRV_BROKEN_RUNS` | `3` | Consecutive default-branch runs failing the same way that make a test broken; at least 3. |
| `ASTER_SRV_MAX_BODY_BYTES` | 64 MiB | Largest upload, before and after decompression. |
| `ASTER_SRV_RAW_BUCKET` | unset | A GCS bucket that keeps each upload as sent, using the pod's Workload Identity. Give the bucket a lifecycle rule that deletes objects after the retention period. |
| `ASTER_SRV_IAP_AUDIENCE` | unset | The Google IAP audience the dashboard accepts, exact or a prefix ending in `/`. Unset turns the dashboard off. See [Dashboard](#dashboard). |
| `ASTER_SRV_IAP_AUDIENCE` | unset | The Google IAP audience the dashboard accepts: exactly `/projects/NUMBER/global/backendServices/ID`. A project-wide prefix is refused at startup, since it would accept an assertion for any other backend service of the project. Unset turns the dashboard off. See [Dashboard](#dashboard). |
| `ASTER_SRV_UI_EMAIL_DOMAINS` | unset | Comma-separated domains (`example.com`, `*.example.com`) whose emails may view the dashboard. Unset allows anyone who passes IAP. |
| `ASTER_SRV_UI_DEV_EMAIL` | unset | The viewer to trust when no IAP audience is set. Development only. |
| `ASTER_SRV_QUARANTINE_RUNS` | `2` | Different runs with a definite flake in 7 days that quarantine a test; `0` turns automatic quarantine off. |
Expand All @@ -412,7 +412,9 @@ Pages are sent with a strict `Content-Security-Policy`, `nosniff`,
| `ASTER_SRV_CACHE_SIGNER` | unset | The pod's Workload Identity service account email. Required with `ASTER_SRV_CACHE_BUCKET`; blob URLs are signed as this account through `signBlob`. |
| `ASTER_SRV_CACHE_DAYS` | `14` | Drop manifests unused this long, then blobs no remaining manifest names, and uncommitted uploads older than this. |
| `ASTER_SRV_CACHE_WRITERS` | `*=refs/heads/<default branch>` | Comma-separated `owner/name=ref` or `*=ref` patterns that may upload and commit. A pattern ending in `*` matches that prefix. The pattern is compared with the token's `ref` claim. |
| `ASTER_SRV_CACHE_WRITER_WORKFLOWS` | unset | Comma-separated prefixes of the token's `job_workflow_ref` claim (`owner/repo/.github/workflows/ci.yml@`; each must contain `@`). When set, a CI token writes the cache or uploads test results only from a matching workflow, in addition to the ref rule. Unset applies no workflow limit, so existing deployments keep working until it is set. Static tokens are exempt. |
| `ASTER_SRV_CACHE_WRITER_WORKFLOWS` | unset | Comma-separated prefixes of the token's `job_workflow_ref` claim (`owner/repo/.github/workflows/ci.yml@`; each must contain `@`). When set, a CI token writes the cache or uploads test results only from a matching workflow, in addition to the ref rule. Required whenever `ASTER_SRV_CACHE_WRITERS` is set (startup fails otherwise unless `ASTER_SRV_ALLOW_ANY_WRITER_WORKFLOW=1`, a development escape hatch). Static tokens are exempt. |
| `ASTER_SRV_REPOSITORY_OWNER_IDS` | unset | Comma-separated `owner/name=id` or `owner/*=id`. A token for a matching repository must carry that `repository_owner_id` claim, so a repository recreated under the same name by someone else is refused with `403`. |
| `ASTER_SRV_DEV_BLOB_ROUTE` | unset | `1` makes the in-memory blob store answer `PUT\|GET /<bucket>/cas/<sha>`, the test stand-in for a signed GCS URL. Tests and local development only; no deploy sets it and the route does not exist without it. |
| `ASTER_SRV_PUBLIC_URL` | unset | The https URL people reach the server at (`https://aster-srv.example.com`); the `iss` and `aud` of person tokens. Required for [Laptop sign-in](#laptop-sign-in). With an https URL every response carries `Strict-Transport-Security: max-age=31536000; includeSubDomains`. An `http://` URL is refused at startup unless `ASTER_SRV_ALLOW_HTTP_PUBLIC_URL=1` (development and tests only). |
| `ASTER_SRV_CLI_TOKEN_KEYS` | unset | `kid:secret` pairs, comma-separated. A secret is hex (64+ digits) or base64 (43+ characters) for at least 32 random bytes, as `openssl rand -base64 32` makes; passphrases, short keys and patterned keys (one byte repeated, a repeated block, a counting run, fewer than 16 distinct bytes) stop startup. The first signs person tokens and form MACs; all verify. Setting it also requires `ASTER_SRV_IAP_AUDIENCE` and `ASTER_SRV_UI_EMAIL_DOMAINS`, and forbids `ASTER_SRV_UI_DEV_EMAIL`. Keep it in a secret store. |
| `ASTER_SRV_CLI_TOKEN_TTL` | `43200` | Seconds a person token lives, 60 to 43200 (12 hours; verification enforces the same cap). |
Expand Down Expand Up @@ -565,8 +567,27 @@ Writes accept a static development token, or a CI token whose `ref` matches
`refs/heads/main`) and, when `ASTER_SRV_CACHE_WRITER_WORKFLOWS` is set, whose
`job_workflow_ref` (else `workflow_ref`) starts with one of its prefixes. A
pull-request ref is refused with `403`, so a branch cannot poison the cache,
and so is a person's token. Test-result uploads follow the same workflow
rule; a person's token is refused on every test-result route.
and so is a person's token. Cache writes also need `event_name` `push`
(pull requests, `pull_request_target`, `workflow_run`, merge queues, schedules
and manual dispatches are `403` whatever ref they carry). Test-result uploads
follow the workflow rule and refuse `pull_request_target`, but accept pull
requests, since a retry that passes there is the flake signal.

### Route table and who may call what

`routes::ROUTES` is the one list of routes. Each has an auth class and the
router checks it, from the headers, before it reads a body or runs a handler;
a `/v1/` path the table does not list is `401` without a valid token.

| Class | Routes | Who |
|---|---|---|
| `Public` | `GET /healthz`, `GET /readyz`, `POST /v1/auth/token` | anyone; the exchange needs a valid code |
| `Iap` | `/cli-auth`, `/ui/*` | a person behind IAP |
| `CiOrPerson` | `GET /v1/test-results/{tests,tests/:id,failures,quarantine,clusters,durations,runs/:id/failures}`, `GET /v1/cache/{summary,entries}`, `POST /v1/cache/lookup` | a CI token for its own repository, or a person's token for any served repository named with `?repo=` (read-only; a person's lookups do not count hits) |
| `CiWriter` | `POST /v1/test-results/runs`, `POST /v1/cache/{upload,commit}` | a CI token only, then the writer rules |

A lookup or upload request body is capped at 1 MiB and read only after the
token is verified; a commit may be as large as `ASTER_SRV_MAX_BODY_BYTES`.

## Laptop sign-in

Expand All @@ -582,7 +603,7 @@ an authorization-code flow with PKCE against the CLI's loopback port.
A person's token is an HS256 JWT signed by this server: `iss` is
`ASTER_SRV_PUBLIC_URL`, `aud` is the same URL, `sub` is IAP's stable subject
(`accounts.google.com:<id>`), `email` is the email IAP verified, `scope` is
`cache:read`, with `iat`, `nbf`, `exp` (at most 12 hours after `iat`) and
`read` (it covers the cache and every dashboard dataset), with `iat`, `nbf`, `exp` (at most 12 hours after `iat`) and
`jti`, and a `kid` header. Verification pins HS256, requires every claim, and
allows 30 seconds of clock skew. Tokens issued before `sub`, `nbf` and `email`
were required are refused; those people sign in once more. There
Expand Down
27 changes: 1 addition & 26 deletions crates/aster-srv/src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
//! | `auth_failure` | any 401 or 403 | `subject` if known, `reason`, `detail` |
//! | `rate_limited` | a limit refused the request | `scope`, `subject` if known |
//!
//! Each carries `route` (a fixed template, see [`route_label`]) and
//! Each carries `route` (a fixed template, see [`crate::routes::label`]) and
//! `client_ip` from the request. Tokens, codes, verifiers and assertions are
//! never fields: callers pass reasons and subjects only, and [`crate::log`]
//! redacts token-shaped text as a second line of defence.
Expand Down Expand Up @@ -68,31 +68,6 @@ pub fn was_audited() -> bool {
.unwrap_or(false)
}

/// The templated route of a request, a bounded set safe as a metric label.
pub fn route_label(method: &hyper::Method, path: &str) -> &'static str {
use crate::test_results::query;
match path {
"/healthz" => "/healthz",
"/readyz" => "/readyz",
"/metrics" => "/metrics",
"/cli-auth" => "/cli-auth",
"/v1/healthz" => "/v1/healthz",
"/v1/auth/token" => "/v1/auth/token",
"/v1/test-results/runs" => "/v1/test-results/runs",
"/v1/test-results/durations" => "/v1/test-results/durations",
p if p == "/ui" || p.starts_with("/ui/") => "/ui",
p if p.starts_with("/v1/test-results/") && query::parse_route(p).is_some() => {
"/v1/test-results/query"
}
p if p.starts_with("/v1/cache/") => "/v1/cache",
p if p.starts_with("/v1/") => "/v1/other",
_ => {
let _ = method;
"other"
}
}
}

fn emit(event: &str, route: Option<&'static str>, fields: Value) {
let (ip, route) = CTX
.try_with(|ctx| (Some(ctx.client_ip.clone()), Some(ctx.route)))
Expand Down
57 changes: 48 additions & 9 deletions crates/aster-srv/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde::Deserialize;
use tokio::sync::{Mutex, RwLock};

use crate::config::{Config, RepositoryAllowlist, StaticToken};
use crate::config::{Config, OwnerIds, RepositoryAllowlist, StaticToken};
use crate::person::{Person, TokenIssuer};

/// Who a bearer token names.
Expand All @@ -45,7 +45,8 @@ pub async fn authenticate(
if verifier.is_static(token) {
return verifier.verify(authorization).await.map(Principal::Ci);
}
let header = decode_header(token).map_err(|e| AuthError::Invalid(e.to_string()))?;
let header =
decode_header(token).map_err(|_| AuthError::Invalid("the token is malformed".into()))?;
match header.alg {
Algorithm::HS256 => match tokens {
Some(tokens) => tokens.verify(token).map(Principal::Person),
Expand Down Expand Up @@ -91,6 +92,10 @@ pub struct Identity {
/// `workflow_ref` when the claim is absent. Writer workflow prefixes
/// match it. `None` for a static token.
pub job_workflow: Option<String>,
/// The `event_name` claim (`push`, `pull_request`, `workflow_dispatch`,
/// `merge_group`...): what triggered the run. Cache writes need `push`.
/// `None` for a static token.
pub event_name: Option<String>,
/// A configured static token, not an OIDC token.
pub static_token: bool,
}
Expand Down Expand Up @@ -119,12 +124,18 @@ impl AuthError {

/// The reply to send: 403 for a valid token that may not use the
/// server, 401 for everything else.
///
/// The reply says only that the token is not valid. The reason, which
/// never contains the token, goes to the log.
pub fn into_reply(self) -> crate::Reply {
let status = if matches!(self, AuthError::Forbidden(_)) {
403
} else {
401
};
if let AuthError::Invalid(reason) = &self {
crate::log::warn("token refused", serde_json::json!({"reason": reason}));
}
crate::Reply::error(status, self.to_string())
}
}
Expand All @@ -133,7 +144,7 @@ impl std::fmt::Display for AuthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuthError::Missing => write!(f, "send a bearer token"),
AuthError::Invalid(reason) => write!(f, "the token is not valid: {reason}"),
AuthError::Invalid(_) => write!(f, "the token is not valid"),
AuthError::Forbidden(repository) => {
write!(f, "{repository} may not use this server")
}
Expand All @@ -156,6 +167,10 @@ struct Claims {
workflow: Option<String>,
#[serde(default)]
job_workflow_ref: Option<String>,
#[serde(default)]
event_name: Option<String>,
#[serde(default, deserialize_with = "string_or_number")]
repository_owner_id: Option<String>,
}

/// GitHub sends `run_id` as a string; accept a number too rather than
Expand All @@ -182,6 +197,7 @@ pub struct Verifier {
audience: String,
issuer: String,
allowlist: RepositoryAllowlist,
owner_ids: OwnerIds,
static_tokens: Vec<StaticToken>,
/// Where keys come from; `None` uses only keys added with [`Self::with_key`].
jwks_url: Option<String>,
Expand All @@ -198,6 +214,7 @@ impl Verifier {
audience: config.audience.clone(),
issuer: config.issuer.clone(),
allowlist: config.repositories.clone(),
owner_ids: config.owner_ids.clone(),
static_tokens: config.static_tokens.clone(),
jwks_url: Some(format!(
"{}/.well-known/jwks",
Expand Down Expand Up @@ -243,11 +260,13 @@ impl Verifier {
sha: None,
workflow: None,
job_workflow: None,
event_name: None,
static_token: true,
});
}

let header = decode_header(token).map_err(|e| AuthError::Invalid(e.to_string()))?;
let header = decode_header(token)
.map_err(|_| AuthError::Invalid("the token is malformed".into()))?;
if header.alg != Algorithm::RS256 {
return Err(AuthError::Invalid(
"tokens must be signed with RS256".into(),
Expand All @@ -262,10 +281,16 @@ impl Verifier {
validation.set_audience(&[&self.audience]);
validation.set_issuer(&[&self.issuer]);
validation.set_required_spec_claims(&["exp", "aud", "iss"]);
validation.validate_nbf = true;
validation.leeway = 5;
let claims = decode::<Claims>(token, &key, &validation)
.map_err(|e| AuthError::Invalid(e.to_string()))?
.map_err(|e| AuthError::Invalid(format!("verification failed: {:?}", e.kind())))?
.claims;
if !self.allowlist.allows(&claims.repository) {
if !self.allowlist.allows(&claims.repository)
|| !self
.owner_ids
.accepts(&claims.repository, claims.repository_owner_id.as_deref())
{
return Err(AuthError::Forbidden(claims.repository));
}
let workflow = non_empty(claims.workflow);
Expand All @@ -276,6 +301,7 @@ impl Verifier {
sha: non_empty(claims.sha),
job_workflow: non_empty(claims.job_workflow_ref).or_else(|| workflow.clone()),
workflow,
event_name: non_empty(claims.event_name),
static_token: false,
})
}
Expand Down Expand Up @@ -308,12 +334,16 @@ impl Verifier {
.json()
.await
.map_err(|e| AuthError::Invalid(format!("cannot read signing keys: {e}")))?;
let mut keys = self.keys.write().await;
// The fetched set replaces the old one: the map stays as small as the
// issuer's set, and a key the issuer withdrew stops verifying.
let mut fetched = HashMap::new();
for jwk in &set.keys {
if let (Some(id), Ok(key)) = (&jwk.common.key_id, DecodingKey::from_jwk(jwk)) {
keys.insert(id.clone(), key);
fetched.insert(id.clone(), key);
}
}
let mut keys = self.keys.write().await;
*keys = fetched;
keys.get(kid)
.cloned()
.ok_or_else(|| AuthError::Invalid("unknown signing key".to_string()))
Expand Down Expand Up @@ -502,7 +532,7 @@ mod tests {
"ASTER_SRV_REPOSITORIES" => Some("ArchAstro/*".into()),
"ASTER_SRV_STATIC_TOKENS" => Some("ArchAstro/dev=dev-token-0123456789".into()),
"ASTER_SRV_PUBLIC_URL" => Some("https://aster-srv.example.com".into()),
"ASTER_SRV_IAP_AUDIENCE" => Some("/projects/1/global/backendServices/".into()),
"ASTER_SRV_IAP_AUDIENCE" => Some("/projects/1/global/backendServices/4242".into()),
"ASTER_SRV_UI_EMAIL_DOMAINS" => Some("example.com".into()),
"ASTER_SRV_CLI_TOKEN_KEYS" => Some(keys.clone()),
_ => None,
Expand Down Expand Up @@ -752,6 +782,11 @@ mod tests {

let mut verifier = Verifier::new(&config());
verifier.jwks_url = Some(url);
// A key the issuer no longer lists, held from an earlier fetch.
verifier
.keys
.get_mut()
.insert("withdrawn".into(), DecodingKey::from_secret(b"x"));
let verifier = Arc::new(verifier);
let bearer = format!("Bearer {}", token(claims("ArchAstro/firstlanding"), "slow"));
let mut calls = tokio::task::JoinSet::new();
Expand All @@ -771,6 +806,10 @@ mod tests {
1,
"one fetch serves them all"
);
assert!(
!verifier.keys.read().await.contains_key("withdrawn"),
"a refetch drops keys the issuer withdrew"
);

// A key the issuer really does not have still fails, and does not refetch within a minute.
let unknown = format!("Bearer {}", token(claims("ArchAstro/firstlanding"), "nope"));
Expand Down
12 changes: 11 additions & 1 deletion crates/aster-srv/src/cache/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,17 @@ mod store;

use crate::db::Migration;

pub use policy::workflow_allowed;
pub use policy::{results_writer_allowed, workflow_allowed};

/// Whether a signed-in person may read `repo`'s data: their email domain is
/// allowed and the server serves the repository.
pub fn person_may_browse(
app: &crate::App,
person: &crate::person::Person,
repo: &str,
) -> Result<(), &'static str> {
policy::person_browser(&app.iap, person, repo, &app.config.repositories)
}
pub use routes::{handle, Headers};
pub use signing::{expires_at, FixedSigner, Signer, URL_TTL};
pub use store::{run_retention, spawn, BlobStore, Report};
Expand Down
Loading
Loading