Skip to content

Commit 9c834e7

Browse files
author
tom
committed
Merge branch 'chore/release-0.9.8' into 'main'
Release 0.9.8 (promote develop → main) Closes #12, #13, #15, #14, and #16 See merge request pub-projects/hort!86
2 parents d42c28d + 5a24858 commit 9c834e7

170 files changed

Lines changed: 19213 additions & 2718 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.cargo/audit.toml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,30 @@ ignore = [
8383
# once axum-server (or hyper-rustls) migrate to the same successor
8484
# API.
8585
"RUSTSEC-2025-0134",
86+
87+
# quick-xml 0.39.4 - two high-severity DoS advisories (both 2026-06-29):
88+
# RUSTSEC-2026-0194 - quadratic run time checking a start tag for
89+
# duplicate attribute names.
90+
# RUSTSEC-2026-0195 - unbounded namespace-declaration allocation in
91+
# `NsReader` enabling memory-exhaustion DoS.
92+
# The fix is quick-xml >= 0.41.0, but NO `object_store` release adopts it
93+
# yet: our pinned `object_store` 0.13.2 requires `quick-xml ^0.39.0`, and
94+
# even the latest `object_store` 0.14.0 requires `^0.40.1` (i.e. < 0.41.0),
95+
# which is STILL in the vulnerable range. So no `cargo update`, `[patch]`,
96+
# or `object_store` bump clears these today - there is simply no compatible
97+
# upstream release. (Bumping to `object_store` 0.14 would also drag the
98+
# `reqwest 0.13` double-TLS-verifier problem the root `Cargo.toml`
99+
# deliberately pins against, for no security gain.)
100+
# Risk is bounded: `quick-xml` is reached ONLY through `object_store`
101+
# parsing XML *responses from the operator-configured S3/Azure backend*
102+
# (ListObjects, multipart, error XML), fetched over TLS verified per
103+
# ADR 0010. Both advisories are DoS requiring crafted XML, so exploitation
104+
# needs a hostile / MITM'd storage endpoint - NOT reachable from the
105+
# artifact push/pull path by an external user. Time-boxed risk acceptance.
106+
# REVISIT TRIGGER: drop BOTH IDs the instant an `object_store` release
107+
# depends on `quick-xml >= 0.41.0` (then `cargo update -p object_store
108+
# -p quick-xml`). Active-graph (object_store is built into hort-server /
109+
# hort-worker), so MIRRORED in `deny.toml` (NOT AUDIT-ONLY).
110+
"RUSTSEC-2026-0194",
111+
"RUSTSEC-2026-0195",
86112
]

.claude/commands/hort-architect.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -583,9 +583,9 @@ These bullets are **review-only** — none are enforced by compile-error or by t
583583
- [ ] **Persisting claim sets on `api_tokens`, `users`, or any other long-lived static-token row.** The claim-RBAC design (ADR 0012) positively chose to leave long-lived static tokens under-privileged for non-admin claim authority. The PAT-authenticated principal carries `claims: []` or `claims: ["admin"]` (the latter only when `user.is_admin`). **A proposal to add `users.claims`, `api_tokens.claims`, `machine_identities.claims`, or any equivalent is a hard block in review.** The right way to grant non-admin authority to a long-lived-token actor is direct `PermissionGrant` rows with `subject = User(sa.id)` (service-account pattern), NOT mapped-claim inheritance. Re-proposing must re-open ADR 0012.
584584
- [ ] **Inventing claim names at runtime.** All claim names come from `claim_mappings`. Code paths that synthesise claim names from string patterns ("groups starting with 'team-' are org-claims", "groups containing 'admin' get the admin claim", etc.) re-introduce the operator-bypass that explicit declaration was meant to prevent. The only synthetic claim allowed is the `admin` claim derived from `user.is_admin=true`. Adding another synthetic claim requires re-opening ADR 0012.
585585
- [ ] **Adding a third `GrantSubject` variant without a recorded design decision.** ADR 0012 closes the subject taxonomy at two variants — `Claims(Vec<String>)` and `User(Uuid)`. Adding `Group(Uuid)`, `ServiceAccountToken(Uuid)`, `ExternalIdentity(...)`, or similar without a new ADR is a hard block. The taxonomy is structurally load-bearing for the evaluator's match logic and for the operator's audit story.
586-
- [ ] **Reintroducing a server-side `roles` table.** Bundling lives in operator-side templating (YAML anchors, Helm partials, Terraform locals). A schema-level `roles` table re-introduces the RBAC-vs-ABAC bifurcation ADR 0012 deliberately collapsed. A new ADR is the required process. (The `service_account_permission_for_role` field is a code-level expansion of the fixed `developer`/`reader` enum — explicitly not a data-layer roles table.)
586+
- [ ] **Reintroducing a server-side `roles` table.** Bundling lives in operator-side templating (YAML anchors, Helm partials, Terraform locals). A schema-level `roles` table re-introduces the RBAC-vs-ABAC bifurcation ADR 0012 deliberately collapsed. A new ADR is the required process. (No code-level role expansion exists either: service-account authority is exclusively explicit `PermissionGrant`s, and the issued-token cap snapshots them at issuance — ADR 0044.)
587587
- [ ] **`PermissionGrant` linter rejected at apply but applied via a back door.** The `ApplyConfigUseCase` linter is the only audited path. A direct DB insert, an admin REST endpoint that bypasses the use case, or a migration that backfills grants bypasses the audit story. Review must catch any back-door reintroduction.
588-
- [ ] **Token-kind discriminator stored as a string in `CallerPrincipal.claims` (or any authz-claim set) instead of the typed `token_kind` field.** Per ADR 0012 and ADR 0013: `cli_session` / `service_account` / `refresh` are token-kind facts, not authz claims. Folding them into `claims` re-introduces exactly the runtime-invented-claim-name footgun and lets a marker string accidentally satisfy a `Claims([..])` grant. The session markers ride `CallerPrincipal.token_kind: Option<TokenKind>`; whoami / deny-hint match the typed field. The `service_account_permission_for_role` field (a code-level expansion of the fixed `developer`/`reader` enum) is the *only* sanctioned role-name→permission mapping and it never touches `claims`. Re-proposing string markers in `claims` re-opens ADR 0012.
588+
- [ ] **Token-kind discriminator stored as a string in `CallerPrincipal.claims` (or any authz-claim set) instead of the typed `token_kind` field.** Per ADR 0012 and ADR 0013: `cli_session` / `service_account` / `refresh` are token-kind facts, not authz claims. Folding them into `claims` re-introduces exactly the runtime-invented-claim-name footgun and lets a marker string accidentally satisfy a `Claims([..])` grant. The session markers ride `CallerPrincipal.token_kind: Option<TokenKind>`; whoami / deny-hint match the typed field. There is no role-name→permission mapping anywhere: service-account authority is explicit grants only (ADR 0044), and SA bearers carry `claims: []`. Re-proposing string markers in `claims` re-opens ADR 0012.
589589

590590
### Read-handler anonymous-by-default (review-only)
591591

.gitguardian.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ secret:
2121
- name: "Test RSA key for unit tests"
2222
match: "END PRIVATE KEY"
2323

24+
# E2E fixture cosign key (scripts/native-tests/fixtures/cosign/) — also
25+
# covered by the path rules above; the header match keeps the fixture
26+
# suppressed if it is ever moved outside the ignored paths.
27+
- name: "E2E fixture encrypted Sigstore private key"
28+
match: "ENCRYPTED SIGSTORE PRIVATE KEY"
29+
2430
# Default test passwords used in examples and unit tests
2531
- name: "Default test password"
2632
match: "admin123"

.github/CODEOWNERS

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Code owners — require maintainer review before a change to these paths merges.
2+
#
3+
# NOTE: this gates the MERGE, not the pre-merge CI run (a PR runs the workflow
4+
# from its own head). It complements — does not replace — the fork-run-approval
5+
# and short-lived reader-token controls documented in
6+
# docs/ci/hort-quarantine-integration.md.
7+
#
8+
# Enforcement requires branch protection / a ruleset with
9+
# "Require review from Code Owners" on main / develop. On a single-maintainer
10+
# repo, leave required-review OFF (you cannot approve your own PR) until a
11+
# maintainers team exists; the entries below are then live.
12+
13+
# CI / Actions surface — the privileged, secret/OIDC-bearing definitions.
14+
/.github/ @project-hort/maintainers
15+
/.github/workflows/ @project-hort/maintainers
16+
/.github/actions/ @project-hort/maintainers
17+
18+
# Deploy + gitops config (service-account grants, federation trust anchors).
19+
/deploy/ansible/files/gitops/ @project-hort/maintainers
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
name: Hort quarantine triage
2+
description: >
3+
Diagnose a FAILED hort dependency-resolution gate (`cargo fetch` against hort
4+
could not acquire the locked dependency set). Classifies each blocked pin
5+
against hort's authoritative status (the discovery endpoint) and posts one
6+
explanatory PR comment.
7+
8+
It does NOT change job status. The resolve step's failure already reds the gate
9+
(which is the merge-blocking signal) — this only explains WHY the gate is red
10+
and whether a retry is the right move. A green gate always means the code was
11+
actually built against a clean dependency set.
12+
13+
inputs:
14+
active:
15+
description: "'true' only on the same-repo MR gate (never fork PRs)."
16+
required: true
17+
hort_url:
18+
required: false
19+
default: 'https://registry.hort.rs'
20+
repo_key:
21+
required: false
22+
default: 'cargo-virtual'
23+
hort_token:
24+
description: Short-lived hort reader bearer (the `token` output of hort-auth).
25+
required: true
26+
27+
runs:
28+
using: composite
29+
steps:
30+
- name: Classify blocked dependencies
31+
id: classify
32+
if: ${{ inputs.active == 'true' }}
33+
shell: bash
34+
env:
35+
HORT_URL: ${{ inputs.hort_url }}
36+
REPO: ${{ inputs.repo_key }}
37+
HORT_TOKEN: ${{ inputs.hort_token }}
38+
run: |
39+
set -uo pipefail
40+
# `cargo fetch --locked` failed, so some locked dep could not be acquired.
41+
# Classify each registry pin via hort's authoritative status. Cargo.lock
42+
# is the committed, network-free pinned set; discovery's `status.kind` is
43+
# the source of truth. Wire contract —
44+
# hort_domain::entities::discovery::DiscoveryVersionStatus,
45+
# #[serde(tag="kind", rename_all="snake_case")]:
46+
# released | quarantined{quarantine_until} |
47+
# quarantined_awaiting_release{...} | rejected | scan_indeterminate | unknown
48+
rejected=(); quarantined=(); stuck=(); unknown=(); latest_until=""
49+
while read -r name ver; do
50+
entry=$(curl -sS --max-time 15 -H "Authorization: Bearer ${HORT_TOKEN}" \
51+
"${HORT_URL}/api/v1/repositories/${REPO}/discovery/versions/${name}" 2>/dev/null \
52+
| jq -c --arg v "$ver" '.versions[]? | select(.version==$v)' 2>/dev/null || true)
53+
[ -z "$entry" ] && continue
54+
case "$(printf '%s' "$entry" | jq -r '.status.kind // empty')" in
55+
rejected|scan_indeterminate) rejected+=("${name}@${ver}") ;;
56+
quarantined_awaiting_release) stuck+=("${name}@${ver}") ;;
57+
unknown) unknown+=("${name}@${ver}") ;;
58+
quarantined)
59+
quarantined+=("${name}@${ver}")
60+
until=$(printf '%s' "$entry" | jq -r '.status.quarantine_until // empty')
61+
# RFC-3339 UTC sorts lexically == chronologically.
62+
if [ -n "$until" ] && { [ -z "$latest_until" ] || [ "$until" \> "$latest_until" ]; }; then
63+
latest_until="$until"
64+
fi
65+
;;
66+
esac
67+
done < <(awk '
68+
/^\[\[package\]\]/ { name=""; ver=""; src=0 }
69+
/^name = / { gsub(/[",]/,""); name=$3 }
70+
/^version = / { gsub(/[",]/,""); ver=$3 }
71+
/^source = "registry/ { src=1 }
72+
/^$/ { if (name && ver && src) print name, ver; name=""; ver=""; src=0 }
73+
END { if (name && ver && src) print name, ver }
74+
' Cargo.lock)
75+
76+
list() { printf '%s\n' "$@" | sed 's/^/- `/; s/$/`/'; }
77+
78+
# Dominant diagnosis. `rejected` and `stuck` are the "a retry will not
79+
# help" cases and take precedence over a plain quarantine window.
80+
if [ ${#rejected[@]} -gt 0 ]; then
81+
{ echo "verdict=rejected"; echo "body<<HORT_EOF"
82+
echo "### 🔴 Blocked: dependency scanned & REJECTED by hort"
83+
echo ""
84+
echo "A retry will **not** help — this is the supply-chain gate working. Investigate / replace:"
85+
echo ""; list "${rejected[@]}"
86+
echo "HORT_EOF"; } >> "$GITHUB_OUTPUT"
87+
elif [ ${#stuck[@]} -gt 0 ]; then
88+
{ echo "verdict=stuck"; echo "body<<HORT_EOF"
89+
echo "### 🟠 Stuck: past the quarantine window with no release authority (ADR 0007)"
90+
echo ""
91+
echo "These cleared the quarantine *time* window but no release authority fired, so **a timed retry will not release them.** They need an operator **curator waive** or **admin override**:"
92+
echo ""; list "${stuck[@]}"
93+
echo "HORT_EOF"; } >> "$GITHUB_OUTPUT"
94+
elif [ ${#quarantined[@]} -gt 0 ]; then
95+
{ echo "verdict=quarantined"; echo "until=${latest_until:-unknown}"; echo "body<<HORT_EOF"
96+
echo "### ⏳ Blocked by quarantine (retryable)"
97+
echo ""
98+
echo "**Not a code failure** — the gate could not source ${#quarantined[@]} dependency(ies) still inside hort's quarantine window. Re-run the gate after they clear."
99+
echo ""
100+
echo "**Earliest retry:** \`${latest_until:-the quarantine window}\`"
101+
echo ""; list "${quarantined[@]}"
102+
echo ""
103+
echo "_Prefetch on the feature branch normally warms these before the merge build, so this should be rare._"
104+
echo "HORT_EOF"; } >> "$GITHUB_OUTPUT"
105+
elif [ ${#unknown[@]} -gt 0 ]; then
106+
{ echo "verdict=unknown"; echo "body<<HORT_EOF"
107+
echo "### ❔ Dependencies not yet ingested by hort"
108+
echo ""
109+
echo "Not in hort yet (prefetch may not have run or cascaded); they will ingest + quarantine on first fetch:"
110+
echo ""; list "${unknown[@]}"
111+
echo "HORT_EOF"; } >> "$GITHUB_OUTPUT"
112+
else
113+
echo "verdict=none" >> "$GITHUB_OUTPUT"
114+
echo "No pinned dependency is quarantined / rejected / stuck in hort — the resolve failure is unrelated to quarantine (network, lockfile drift, or a genuinely missing crate). Gate stays red; see the job log."
115+
fi
116+
117+
- name: Comment the diagnosis
118+
if: ${{ inputs.active == 'true' && steps.classify.outputs.verdict != '' && steps.classify.outputs.verdict != 'none' && github.event_name == 'pull_request' }}
119+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
120+
env:
121+
BODY: ${{ steps.classify.outputs.body }}
122+
with:
123+
script: |
124+
await github.rest.issues.createComment({
125+
owner: context.repo.owner,
126+
repo: context.repo.repo,
127+
issue_number: context.payload.pull_request.number,
128+
body: process.env.BODY,
129+
});

0 commit comments

Comments
 (0)