diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c173cef898..0579e4e622 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,6 +5,24 @@ https://github.com/spiffe/spire/blob/main/CONTRIBUTING.md 2. Please remember to include a DCO on every commit (`git commit -s`) https://github.com/apps/dco + +3. How the review process works ("ball in court"): + - When you open this PR (or mark it ready for review), a maintainer is + automatically assigned to it. The assignee shows whose turn it is to act + next, so you can always see who currently holds the ball. + - Pushing new commits does not automatically reassign the PR to the + maintainer. Once you are done addressing comments, re-request a review + from the assigned maintainer (the "Reviewers" section of the PR has a + refresh icon next to their name). This puts the ball back in their court + so they know it is ready for another look. + - When addressing review comments, please add new commits rather than + force-pushing. Individual commits that address comments are easier to + review than a single commit containing all the changes again. Pull + requests are squashed at merge time, so there is no need to squash and + force-push in the PR. + - Please also look at any comments from Copilot and address them if needed. + Posting a short reply to each one helps reviewers see whether it has been + addressed or consciously considered, even when no code change is needed. --> **Pull Request check list** diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..e27cd9af89 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,230 @@ +# Copilot instructions for SPIRE + +SPIRE is the reference implementation of the SPIFFE APIs. It issues SPIFFE +identities (SVIDs) to workloads in the SVID formats defined by the SPIFFE +specifications. The codebase is Go and is organized around two long-running +processes, the SPIRE Server and the SPIRE Agent, plus a plugin system. + +When reviewing or generating code, weigh the concerns covered below. All of +them matter, and more than one often applies to the same change: compatibility +guarantees, SPIFFE spec conformance, security and usability, and project +conventions. + +## Review checklist + +These questions come from the SPIRE maintainer review guidelines in +`MAINTAINERS.md` and are advisory prompts to surface issues, not a rigid gate: + +- Is the use case the change addresses clearly understood? +- Does the change break any current user's expectations of behavior (a + regression)? +- Can the change be misconfigured, and if so what is the impact? +- Does the change adhere to the SPIRE compatibility guarantee (see the + Compatibility guarantees section below)? +- What are the failure modes? Can SPIRE keep running? +- If something goes wrong, will it be clear to the operator what happened and + how to fix it? +- If the change introduces additional configurables, could some or all of them + be replaced with a programmatic decision? + +Changes to particularly sensitive areas, such as the agent's cache manager or +the server's CA, warrant extra scrutiny. + +## Compatibility guarantees + +SPIRE makes strong compatibility promises documented in `doc/upgrading.md`. +Treat a violation of these as a blocking issue. + +- **Server-to-server version skew.** Servers in the same cluster must operate + within +/- 1 minor version of each other. Do not introduce a change that + breaks a server one minor version behind or ahead (for example a new field or + RPC that an adjacent server cannot produce or understand). +- **Agent-to-server version skew.** Agents may be up to one minor version older + than the oldest server and must never be newer than the oldest server. Do not + add behavior that requires an agent and server to be the same version, or that + breaks an agent one minor version behind. +- **Upgrade and downgrade paths.** Only single-minor-version jumps are + supported, servers upgrade before agents, and SPIRE supports zero-downtime + rolling upgrades when more than one server is present. Do not assume state + written by a version more than one minor prior. +- **Built-in plugin config and behavior compatibility.** A backwards-incompatible + change to a built-in plugin (config semantics, selectors produced, etc.) must + log a deprecation warning and keep backwards compatibility for one minor + version. Do not rename, retype, or remove a config key without a deprecation + cycle. +- **Plugin interface compatibility.** A breaking change to a plugin interface + must keep existing plugins working for one minor version cycle, with warnings. +- **Deprecation log markers.** Deprecation warnings must include the structured + field `alert=true`. Configuration deprecations add `alert_type=deprecated_config` + and deprecated plugin services add `alert_type=deprecated_service`. +- **Datastore/SQL changes.** Datastore schema changes must ship in at least one + full minor release cycle before any code change depends on them. +- **Experimental features.** Features gated behind the `experimental` config + section are exempt from the guarantees above. If a change touches a feature + that was experimental, confirm the experimental marker is still accurate. + +## SPIFFE specification conformance + +SPIRE implements the SPIFFE specifications. The canonical, authoritative set of +specs lives at https://github.com/spiffe/spiffe/tree/main/standards. The set of +specifications and SVID profiles evolves over time, and new profiles are added, +so do not assume the specs are limited to the ones you already know from +training. + +When a change touches spec-defined behavior (an SVID format, trust domain +parsing, the Workload API request/response shape, federation bundle format, +etc.), treat the specs in that repository as authoritative and verify +conformance against them rather than relying on prior knowledge, which may be +stale or incomplete. If you cannot consult the spec, raise spec-conformance +concerns as questions rather than asserting a defect. Likewise, flag +reinterpretations of ambiguous spec areas as questions for maintainers rather +than as defects. + +## Security and usability + +SPIRE is security-critical infrastructure. Watch for changes that could weaken +identity issuance, trust domain or bundle handling, SVID validation, key +management, or authentication and authorization between components. Consider +whether a change could be misconfigured, and what the impact of misconfiguration +would be. Vulnerabilities are reported privately to security@spiffe.io, not via +public issues or PRs. + +SPIRE solves a complicated problem, so features, configurables, log and error +messages, documentation, and naming must stay accessible to people who are not +deeply familiar with SPIFFE or authentication systems. The maintainer guidelines +in `MAINTAINERS.md` set these expectations: + +- **Secure by default, then "it just works".** Decisions should favor secure + defaults first and ease of use second, in that order. +- **Minimize configurables.** Keep the number of configuration options as small + as possible, especially when many users would need to set the option, or when + its value (and its extremes) could significantly affect SPIRE performance, + reliability, or security. Prefer a programmatic decision over a new + configurable where one is feasible. +- **The beginner measure.** A beginner should be able to quickly understand a + feature or configurable and its impacts, and should be able to troubleshoot + and be clearly informed when something important goes wrong. Favor clear, + actionable log and error messages over silent failure. + +## Repository layout + +- `cmd/{spire-server,spire-agent}/`: CLI implementations of the server and + agent commands. +- `pkg/{agent,server}/`: main logic of the agent and server processes and their + support packages. +- `pkg/common/`: functionality shared by agent, server, and plugins. +- `pkg/{agent,server}/plugin//`: built-in plugin implementations. +- `proto/spire/common/`: shared protobuf definitions (package `spire.common`), + with plugin-related common types under `proto/spire/common/plugin/`. +- `proto/private/`: internal protobuf definitions that are not part of the + public API, for example `proto/private/server/journal/`. + +The public gRPC API and plugin interface protobufs live in separate +repositories ([`spire-api-sdk`](https://github.com/spiffe/spire-api-sdk) and +[`spire-plugin-sdk`](https://github.com/spiffe/spire-plugin-sdk), both +dependencies in `go.mod`), not in this repository. + +Packages should be exported through interfaces, and interaction with a package +should go through its interface. Define an interface in its own lowercase file +named after the interface. + +Platform-specific code is split across files guarded by build constraints, by +convention named `_posix.go` and `_windows.go`. A change to one +platform's file usually needs a corresponding change in its sibling so that +behavior and validation stay consistent across platforms. + +## Go conventions + +- Write idiomatic Go. Prefer standard library and existing helpers over manual + plumbing, and match the patterns in sibling files (error wrapping, validation, + output formatting). +- Errors start with a lower case letter. Neither error messages nor log + messages end with a period. +- Log messages use structured logging fields to convey context rather than + string formatting, which increases message cardinality and hinders + aggregation. Field names should be the constants defined in + `pkg/common/telemetry/names.go` rather than ad-hoc strings. Log message text + itself uses standard casing. + +### Language version + +This project targets a modern Go toolchain, and the toolchain is updated +periodically. Treat the `.go-version` file at the repository root as the single +source of truth for the language version: assume all language and standard +library features up to that version are available, and do not flag valid modern +Go as an error based on an assumed older version. Do not hardcode a version +assumption from this document. In particular, do not raise any of the following +(all valid in the toolchain this project uses): + +- **Ranging over an integer.** `for range n` and `for i := range n` where `n` + is an integer are valid since Go 1.22. Do not claim `range` cannot iterate + over an `int` or that such code does not compile. +- **Per-iteration loop variables.** Since Go 1.22 each loop iteration gets a + fresh copy of the loop variable. A goroutine or closure that captures a `for` + loop variable does not observe later iterations' values, so do not flag the + classic "loop variable captured by reference" race for plain `for range` or + three-clause `for` loops. (Variables declared inside the loop body with `:=` + were already per-iteration before 1.22.) +- **`min`, `max`, and `clear` builtins**, and other features introduced up to + the toolchain's version. +- **`new(x)`** where `x` is an expression rather than a type. + +If you believe a construct fails to compile, prefer assuming it is valid modern +Go rather than reporting a build error based on an older language version. + +## Metrics + +- Label names should be the constants defined in the `telemetry` package + (`pkg/common/telemetry/names.go`), and metrics should be defined centrally in + `telemetry` or its subpackages. +- Count in aggregate: accumulate counts in a loop and emit a single metric after + the loop rather than emitting one metric per iteration. +- Labels must be singular: a label name appears at most once per metric, its + value is never an array or slice, and a given label must appear on every + instance of a metric rather than conditionally. +- Keep `doc/telemetry/telemetry.md` up to date when metrics change, and unit + test metrics where reasonable. + +## Testing + +- Prefer fake implementations over generated mocks. Mocks encode specific call + patterns and are brittle; fakes implement the assumed behavior of a dependency + in one maintainable place. +- Use table-driven tests when there is more than one case, with a `name` field + per case. +- Never use `time.Sleep` to synchronize tests. Use proper synchronization such + as `require.Eventually`, channels, sync primitives, mock clocks, or explicit + control like `os.Chtimes`. +- Include tests and regression coverage for behavior changes. +- Before flagging a data race or test flakiness caused by "parallel execution" + (for example a package-level variable mutated by a test), confirm the affected + tests actually run in parallel by calling `t.Parallel()`. Tests run serially + within a package unless they opt in, and SPIRE intentionally uses package-level + test hooks in some places, so do not assert a parallel-execution race without + that evidence. + +## Building, testing, and linting + +A Makefile drives common tasks and installs the required toolchain as needed. + +- `make build` builds the binaries; `make all` builds, lints, and runs the unit + tests. +- `make test` runs the unit tests, and `make race-test` runs them with the race + detector. Validate code changes with these before considering them done. +- `make lint` runs the linters: `lint-code` (golangci-lint, configured in + `.golangci.yml`) and `lint-md` (markdown). Code and docs should pass lint. +- `make generate` regenerates the protobuf code (`.pb.go`) from `.proto` files + using the pinned `spire-plugin-sdk` version, and `make generate-check` verifies + the generated code is current. After changing a `.proto` file, run + `make generate` and commit the regenerated output. + +## Contribution mechanics + +- Every commit must be signed off with a DCO (`git commit -s`). +- Substantial changes should be tied to a triaged issue. +- Update documentation when behavior or configuration changes. Check whether a + relevant file under `doc/` needs to be updated (for example a plugin's + reference doc). +- When a change adds, removes, or modifies a configuration setting, update the + full reference config files as well: `conf/agent/agent_full.conf` for agent + settings and `conf/server/server_full.conf` for server settings. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4ff9786434..6bbe105f14 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,38 +3,24 @@ updates: - package-ecosystem: gomod directory: "/" schedule: - interval: "daily" + interval: "weekly" time: "09:00" timezone: "America/Los_Angeles" + # Wait for new releases to age before opening update PRs, preserving + # a window for the ecosystem to detect and report a compromised + # release before it is proposed for review. + cooldown: + default-days: 7 + # Collapse all minor and patch updates into a single weekly PR. + # Major bumps remain ungrouped and each one opens its own PR, so + # each breaking change can be reviewed independently. groups: - actions: + minor-and-patch: patterns: - - "github.com/actions/*" - aws-sdk: - patterns: - - "github.com/aws/aws-sdk-go-v2/*" - - "github.com/aws/smithy-go" - azure-sdk: - patterns: - - "github.com/Azure/azure-sdk-for-go/*" - google-cloud-sdk: - patterns: - - "cloud.google.com/go/*" - - "google.golang.org/api" - - "google.golang.org/grpc" - - "github.com/googleapis/*" - golang.org/x: - patterns: - - "golang.org/x/*" - hashicorp-vault: - patterns: - - "github.com/hashicorp/vault/*" - k8s.io: - patterns: - - "k8s.io/*" - sigs.k8s.io: - patterns: - - "sigs.k8s.io/*" + - "*" + update-types: + - "minor" + - "patch" ignore: - dependency-name: "github.com/spiffe/spire-api-sdk" - dependency-name: "github.com/spiffe/spire-plugin-sdk" @@ -49,5 +35,21 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: daily + interval: "weekly" + # Wait for new action releases to age before opening update PRs, so + # that a compromised release has a window to be detected and reported + # by the ecosystem before it is proposed for review (e.g. the March + # 2025 tj-actions/changed-files compromise was reverted within days). + cooldown: + default-days: 7 + # Collapse all minor and patch updates into a single weekly PR. + # Major bumps remain ungrouped and each one opens its own PR, so + # each breaking change can be reviewed independently. + groups: + minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" open-pull-requests-limit: 20 diff --git a/.github/reviewer-pool.json b/.github/reviewer-pool.json new file mode 100644 index 0000000000..98e6f9be79 --- /dev/null +++ b/.github/reviewer-pool.json @@ -0,0 +1,7 @@ +{ + "reviewers": [ + "amartinezfayo", + "sorindumitru", + "MarcosDY" + ] +} diff --git a/.github/workflows/assign_reviewer.yaml b/.github/workflows/assign_reviewer.yaml new file mode 100644 index 0000000000..87d3b972f4 --- /dev/null +++ b/.github/workflows/assign_reviewer.yaml @@ -0,0 +1,150 @@ +name: assign-reviewer + +# Rotating assignee ("ball in court") for pull requests. +# +# This workflow does not request reviewers: the repository's CODEOWNERS file +# already requests the maintainers on every PR. Instead it designates a single +# maintainer as the PR assignee to show who owns the next action ("ball in +# court"), and keeps that up to date as the review progresses: +# - opened / ready_for_review -> a maintainer is chosen at random from the +# pool and set as the assignee. +# - a human re-requests a review while the ball is with the author -> the +# assignee flips to the requested maintainer. +# +# Review submissions (the flip to the author on changes requested, and the +# reviewer staying assigned on approval) are handled by the companion +# assign-reviewer-review-capture and assign-reviewer-review-apply workflows. +# That split exists because review handling needs a write token on PRs from +# forks, which the pull_request_review event does not provide; see those files. +# +# The maintainer is picked at random from the pool, which evens out over time. +# The selection is stateless: no persisted index or external state. The pool is +# defined in .github/reviewer-pool.json, separate from CODEOWNERS, so it can be +# adjusted for availability (leave, inactivity) without touching ownership. +# +# This runs on pull_request_target, which has a write-scoped token even for PRs +# from forks. It is safe because the job never checks out or executes any code +# from the PR; it only reads PR metadata and calls the assignee API, never +# interpolating PR-controlled text into a shell. + +on: + pull_request_target: + types: [opened, ready_for_review, review_requested] + +# Serialize runs per PR and per event action so overlapping events don't race +# on the assignee. The action is part of the group on purpose: when a PR opens, +# GitHub fires the "opened" event together with a burst of CODEOWNERS +# "review_requested" events, and a single group plus cancel-in-progress: false +# makes GitHub cancel all but the latest pending run, which can drop the +# "opened" run that assigns the reviewer. Keeping each action in its own group +# lets "opened" run on its own while same-action bursts still collapse safely. +concurrency: + group: assign-reviewer-${{ github.event.pull_request.number }}-${{ github.event_name }}-${{ github.event.action }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + assign-reviewer: + runs-on: ubuntu-latest + permissions: + contents: read # read .github/reviewer-pool.json + issues: write # add/remove assignees (the issues API endpoint) + pull-requests: write # add/remove assignees on the pull request + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + const action = context.payload.action; + const eq = (a, b) => a.toLowerCase() === b.toLowerCase(); + const assignees = () => (pr.assignees || []).map(a => a.login); + + // The reviewer pool lives in .github/reviewer-pool.json so it has a + // single, discoverable source of truth shared with the apply + // workflow (assign_reviewer_review_apply.yaml). Read it from the + // default branch (the API default ref) so a PR from a fork cannot + // substitute its own pool. + const { data: poolFile } = await github.rest.repos.getContent({ + owner, repo, path: '.github/reviewer-pool.json', + }); + const POOL = JSON.parse( + Buffer.from(poolFile.content, poolFile.encoding).toString('utf8'), + ).reviewers; + const inPool = (login) => POOL.some(p => eq(p, login)); + // The ball is in the author's court only when the author is assigned + // and no pool member other than the author also is, so a re-request + // does not override a maintainer who is still (for example, + // manually) co-assigned. + const authorHoldsBall = () => { + const current = assignees(); + return current.some(a => eq(a, pr.user.login)) && + !current.some(a => inPool(a) && !eq(a, pr.user.login)); + }; + + // Make `login` the sole ball-in-court assignee among flow + // participants (the pool members and the PR author), leaving any + // unrelated, manually-added assignees untouched. + async function setCourt(login) { + const flow = [...POOL, pr.user.login]; + const current = assignees(); + const toRemove = current.filter(a => + flow.some(f => eq(f, a)) && !eq(a, login)); + if (toRemove.length > 0) { + await github.rest.issues.removeAssignees({ + owner, repo, issue_number: pr.number, assignees: toRemove, + }); + } + if (!current.some(a => eq(a, login))) { + await github.rest.issues.addAssignees({ + owner, repo, issue_number: pr.number, assignees: [login], + }); + } + core.info(`Ball in court: ${login}, PR #${pr.number}.`); + } + + // Skip bot-authored PRs (for example Dependabot). These are handled + // by the auto-merge workflow and do not take part in the rotation. + if (pr.user.type === 'Bot' || pr.user.login.endsWith('[bot]')) { + core.info(`PR #${pr.number} authored by bot ${pr.user.login}; skipping.`); + return; + } + + // 1) New PR (or draft promoted to ready): pick a random pool member. + if (action === 'opened' || action === 'ready_for_review') { + if (pr.draft) { + core.info(`PR #${pr.number} is a draft; skipping.`); + return; + } + // Exclude the author so a maintainer who opens a PR is not made + // the assignee of their own PR. + const candidates = POOL.filter(u => !eq(u, pr.user.login)); + if (candidates.length === 0) { + core.info('No eligible maintainers after excluding the author; skipping.'); + return; + } + const reviewer = candidates[Math.floor(Math.random() * candidates.length)]; + core.info(`Selected assignee for PR #${pr.number}: ${reviewer}.`); + await setCourt(reviewer); + return; + } + + // 2) A review was (re-)requested. Move the ball to that maintainer, + // but only when the ball is currently in the author's court (the + // author is the assignee). This keeps the CODEOWNERS auto-request + // that fires on open from overriding the rotated assignee. + if (action === 'review_requested') { + const reviewer = context.payload.requested_reviewer; + if (!reviewer || reviewer.type === 'Bot' || !inPool(reviewer.login)) { + core.info('Review request is not for a pool member; skipping.'); + return; + } + if (!authorHoldsBall()) { + core.info(`Ball is not in the author's court on PR #${pr.number}; not overriding the rotation.`); + return; + } + await setCourt(reviewer.login); + return; + } diff --git a/.github/workflows/assign_reviewer_review_apply.yaml b/.github/workflows/assign_reviewer_review_apply.yaml new file mode 100644 index 0000000000..5b3677f4ad --- /dev/null +++ b/.github/workflows/assign_reviewer_review_apply.yaml @@ -0,0 +1,177 @@ +name: assign-reviewer-review-apply + +# Privileged half of the review ball-in-court update. +# +# Triggered by the completion of assign-reviewer-review-capture via +# workflow_run. A workflow_run run executes from the default branch with a +# write-scoped token even when the originating review was on a PR from a fork, +# which is what lets it move the assignee where the pull_request_review event +# could not. +# +# Ball-in-court rules applied here: +# - reviewer approves -> the reviewer stays assigned (a maintainer merges). +# - changes requested or comment -> the author is assigned (their court). +# +# Security: this never checks out or runs any PR code. The capture artifact is +# treated as untrusted (a fork PR can run a modified capture, since +# pull_request_review uses the PR's own workflow copy): it validates the PR +# number is a positive integer and the review state is one of the known values, +# binds the artifact to the triggering PR by requiring the re-fetched PR head to +# equal the run's head SHA (so a forged pr_number cannot target a victim PR), +# confirms via the API that the named reviewer actually submitted a review in +# that state (so a forged artifact cannot fabricate one), skips bot-authored +# PRs, only ever assigns a login that is in the pool or is the PR author +# re-fetched from the API, and never interpolates artifact data into a shell. + +on: + workflow_run: + workflows: [assign-reviewer-review-capture] + types: [completed] + +# Serialize applies for the same commit so close-together reviews don't race on +# the assignee. The PR number is not in the workflow_run context for fork PRs, +# so the head SHA is used as the grouping key. +concurrency: + group: assign-reviewer-review-apply-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + apply: + runs-on: ubuntu-latest + # Only act when the capture run succeeded (so the artifact exists). + if: github.event.workflow_run.conclusion == 'success' + permissions: + contents: read # read .github/reviewer-pool.json + issues: write # add/remove assignees (the issues API endpoint) + pull-requests: write # add/remove assignees on the pull request + actions: read # download the artifact from the capture run + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: assign-reviewer-review + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require('fs'); + + const { owner, repo } = context.repo; + const eq = (a, b) => a.toLowerCase() === b.toLowerCase(); + + // The reviewer pool lives in .github/reviewer-pool.json so it has a + // single, discoverable source of truth shared with + // assign_reviewer.yaml. This workflow_run job already runs from the + // default branch, so the default ref is the trusted pool. + const { data: poolFile } = await github.rest.repos.getContent({ + owner, repo, path: '.github/reviewer-pool.json', + }); + const POOL = JSON.parse( + Buffer.from(poolFile.content, poolFile.encoding).toString('utf8'), + ).reviewers; + const inPool = (login) => POOL.some(p => eq(p, login)); + + // The artifact comes from a fork-triggered run; treat it as + // untrusted and validate every field before use. + let raw; + try { + raw = JSON.parse(fs.readFileSync('review.json', 'utf8')); + } catch (e) { + core.warning(`Could not read captured review data: ${e.message}`); + return; + } + const prNumber = Number(raw.pr_number); + const state = String(raw.review_state || '').toLowerCase(); + const submitter = String(raw.submitter || ''); + if (!Number.isInteger(prNumber) || prNumber <= 0) { + core.warning('Captured pr_number is not a positive integer; ignoring.'); + return; + } + if (!['approved', 'changes_requested', 'commented'].includes(state)) { + core.info(`Review state "${state}" does not change the court; nothing to do.`); + return; + } + // Only reviews from pool members drive the ball-in-court state. + if (!inPool(submitter)) { + core.info(`Review by ${submitter} is not from a pool member; leaving court unchanged.`); + return; + } + + // Re-fetch the PR so we act on its current author and assignees. + const { data: pr } = await github.rest.pulls.get({ + owner, repo, pull_number: prNumber, + }); + const author = pr.user.login; + const current = (pr.assignees || []).map(a => a.login); + + // Bind the artifact to the PR that actually triggered the capture + // run. pull_request_review runs the PR's own copy of the capture + // workflow, so a fork can ship a modified capture that uploads an + // arbitrary pr_number; the field validation above only checks it is + // a positive integer. Requiring the re-fetched PR's head to equal + // the triggering run's head SHA limits any forged artifact to the + // attacker's own PR (whose head is the only one that can match), + // rather than letting it act on a victim PR. A legitimate apply that + // races a fresh push is skipped and reconciles on the next event. + if (pr.head.sha !== context.payload.workflow_run.head_sha) { + core.info(`PR #${prNumber} head does not match the capture run head; ignoring (forged or stale artifact).`); + return; + } + + // Skip bot-authored PRs (for example Dependabot); they are owned by + // the auto-merge workflow and do not take part in the rotation. + if (pr.user.type === 'Bot' || pr.user.login.endsWith('[bot]')) { + core.info(`PR #${prNumber} authored by bot ${pr.user.login}; skipping.`); + return; + } + + // The apply is asynchronous, so the PR may have closed in between. + if (pr.state !== 'open') { + core.info(`PR #${prNumber} is ${pr.state}; leaving court unchanged.`); + return; + } + // A pool member can leave a comment review on their own PR; don't + // assign them to it (the open handler excludes the author too). + if (eq(submitter, author)) { + core.info(`Self-review by ${submitter} on PR #${prNumber}; leaving court unchanged.`); + return; + } + + // Confirm the claimed review actually exists. The capture is + // fork-modifiable, so without this a forged artifact could fabricate + // a pool member's review on the attacker's own PR. Require a real + // review by `submitter` in `state` before acting on it. + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, repo, pull_number: prNumber, per_page: 100, + }); + const reviewExists = reviews.some(r => + r.user && eq(r.user.login, submitter) && + (r.state || '').toLowerCase() === state); + if (!reviewExists) { + core.info(`No ${state} review by ${submitter} on PR #${prNumber}; ignoring (forged or stale artifact).`); + return; + } + + // Approve keeps the reviewer assigned; otherwise the author owns it. + const target = state === 'approved' ? submitter : author; + + // Make `target` the sole ball-in-court assignee among flow + // participants (pool members and the author), leaving unrelated, + // manually-added assignees untouched. + const flow = [...POOL, author]; + const toRemove = current.filter(a => + flow.some(f => eq(f, a)) && !eq(a, target)); + if (toRemove.length > 0) { + await github.rest.issues.removeAssignees({ + owner, repo, issue_number: prNumber, assignees: toRemove, + }); + } + if (!current.some(a => eq(a, target))) { + await github.rest.issues.addAssignees({ + owner, repo, issue_number: prNumber, assignees: [target], + }); + } + core.info(`Ball in court: ${target}, PR #${prNumber}.`); diff --git a/.github/workflows/assign_reviewer_review_capture.yaml b/.github/workflows/assign_reviewer_review_capture.yaml new file mode 100644 index 0000000000..9c1b5331f4 --- /dev/null +++ b/.github/workflows/assign_reviewer_review_capture.yaml @@ -0,0 +1,47 @@ +name: assign-reviewer-review-capture + +# Unprivileged half of the review ball-in-court update. +# +# The pull_request_review token is read-only for PRs from forks, so the +# assignee write cannot happen here. This workflow only reads the review event +# and writes a small artifact with the data the privileged +# assign-reviewer-review-apply workflow (triggered via workflow_run) needs to +# move the assignee with a write token. +# +# It never writes to the repository and never checks out or runs any PR code. +# Note that pull_request_review runs the PR's own copy of this workflow, so a +# fork PR can run a modified version of this file. That gains no privileges +# (the token stays read-only), but it means the artifact contents are not +# trusted: the apply workflow validates every field, binds the artifact to the +# triggering PR's head SHA, and confirms via the API that the claimed review +# exists before acting on it. + +on: + pull_request_review: + types: [submitted] + +permissions: + contents: read + +jobs: + capture: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require('fs'); + const review = context.payload.review; + const data = { + pr_number: context.payload.pull_request.number, + review_state: (review.state || '').toLowerCase(), + submitter: review.user.login, + }; + fs.writeFileSync('review.json', JSON.stringify(data)); + core.info(`Captured ${data.review_state} review by ${data.submitter} on PR #${data.pr_number}.`); + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: assign-reviewer-review + path: review.json + retention-days: 1 + if-no-files-found: error # fail loudly rather than produce no artifact diff --git a/.github/workflows/dco.yaml b/.github/workflows/dco.yaml index 625527b0c5..8b011424cf 100644 --- a/.github/workflows/dco.yaml +++ b/.github/workflows/dco.yaml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Skip DCO check in merge queue if: github.event_name == 'merge_group' run: | @@ -22,7 +22,7 @@ jobs: exit 0 - name: Set up Python 3.x if: github.event_name != 'merge_group' - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.x' - name: Check DCO diff --git a/.github/workflows/dependabot_automerge.yml b/.github/workflows/dependabot_automerge.yml deleted file mode 100644 index c1265017f7..0000000000 --- a/.github/workflows/dependabot_automerge.yml +++ /dev/null @@ -1,189 +0,0 @@ -name: Dependabot Auto Merge - -on: - workflow_run: - workflows: ["PR Build"] - types: [completed] - -permissions: - contents: write - issues: write - pull-requests: write - -jobs: - auto-merge: - name: auto-merge - runs-on: ubuntu-22.04 - # Only act when CI passed and the PR was opened by Dependabot. The - # workflow_run trigger always runs in the base branch context, so - # GITHUB_TOKEN has full repository permissions regardless of who opened - # the PR, and the PR branch code never executes here. - if: | - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.actor.login == 'dependabot[bot]' - steps: - - name: Get pull request info - id: pr - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RUN_ID: ${{ github.event.workflow_run.id }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - - skip() { - echo "$1" - echo ":warning: **Dependabot auto-merge skipped:** $1" >> "$GITHUB_STEP_SUMMARY" - exit 0 - } - - PR_NUMBER=$(gh api "repos/${REPO}/actions/runs/${RUN_ID}" \ - --jq '.pull_requests[0].number // empty') - if [ -z "$PR_NUMBER" ]; then - skip "No pull request associated with this workflow run." - fi - - PR=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}") - - # Skip if the PR is no longer open (e.g. already in the merge queue). - PR_STATE=$(echo "$PR" | jq -r '.state') - if [ "$PR_STATE" != "open" ]; then - skip "PR #${PR_NUMBER} is not open (state: ${PR_STATE})." - fi - - # Verify PR was opened by Dependabot (immutable field, set at PR creation). - PR_AUTHOR=$(echo "$PR" | jq -r '.user.login') - if [ "$PR_AUTHOR" != "dependabot[bot]" ]; then - skip "PR #${PR_NUMBER} was not opened by Dependabot (author: ${PR_AUTHOR})." - fi - - # Fetch all commits and verify that the PR contains exactly one. - # Dependabot PRs always have a single commit; more than one means - # someone pushed additional commits to the branch. - COMMITS=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/commits") - COMMIT_COUNT=$(echo "$COMMITS" | jq 'length') - if [ "$COMMIT_COUNT" != "1" ]; then - skip "PR #${PR_NUMBER} has ${COMMIT_COUNT} commits (expected 1)." - fi - - # Verify the single commit is cryptographically signed by Dependabot. - # This mirrors the verification done by dependabot/fetch-metadata and - # prevents spoofed commit messages from influencing the update-type - # decision. - COMMIT=$(echo "$COMMITS" | jq '.[0]') - COMMIT_VERIFIED=$(echo "$COMMIT" | jq -r '.commit.verification.verified') - COMMIT_AUTHOR=$(echo "$COMMIT" | jq -r '.author.login') - if [ "$COMMIT_VERIFIED" != "true" ]; then - skip "Commit on PR #${PR_NUMBER} is not verified." - fi - if [ "$COMMIT_AUTHOR" != "dependabot[bot]" ]; then - skip "Commit author on PR #${PR_NUMBER} is not Dependabot (author: ${COMMIT_AUTHOR})." - fi - - COMMIT_MESSAGE=$(echo "$COMMIT" | jq -r '.commit.message') - UPDATE_TYPE=$(echo "$COMMIT_MESSAGE" | grep -oP 'update-type:\s*\K\S+' || true) - - # Allowlist the update-type value before writing to GITHUB_OUTPUT to - # prevent unexpected content from the commit message influencing - # downstream step conditions. - case "$UPDATE_TYPE" in - version-update:semver-major|version-update:semver-minor|version-update:semver-patch) - ;; - *) - UPDATE_TYPE="" - ;; - esac - - HEAD_SHA=$(echo "$COMMIT" | jq -r '.sha') - - echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" - echo "update-type=${UPDATE_TYPE}" >> "$GITHUB_OUTPUT" - echo "head-sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT" - - # Auto-merge all non-major updates. CI catches regressions regardless - # of semver level, and major updates are excluded as they signal - # intentional breaking changes requiring manual review. - - name: Assign milestone and enable auto-merge - if: | - steps.pr.outputs.number != '' && ( - steps.pr.outputs.update-type == 'version-update:semver-patch' || - steps.pr.outputs.update-type == 'version-update:semver-minor' - ) - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ steps.pr.outputs.number }} - HEAD_SHA: ${{ steps.pr.outputs.head-sha }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - - PR_URL="https://github.com/${REPO}/pull/${PR_NUMBER}" - - # Read the base version from source to find the correct milestone. - BASE_VERSION=$(gh api "repos/${REPO}/contents/pkg/common/version/version.go" \ - --jq '.content' | base64 -d | grep -oP 'Base = "\K[^"]+') - echo "Base version: ${BASE_VERSION}" - - # Determine whether to use >= or > BASE_VERSION when searching for - # the target milestone. The release process creates the branch - # release/v{BASE_VERSION} one week before the actual release, at the - # moment the release candidate commit is picked. Once that branch - # exists, commits on main are destined for the next release, so we - # skip the BASE_VERSION milestone and target the one after it. - RELEASE_BRANCH="release/v${BASE_VERSION}" - if gh api "repos/${REPO}/branches/${RELEASE_BRANCH}" > /dev/null 2>&1; then - echo "Release branch ${RELEASE_BRANCH} exists: targeting milestone > ${BASE_VERSION}" - REQUIRE_GT=true - else - echo "Release branch ${RELEASE_BRANCH} does not exist: targeting milestone >= ${BASE_VERSION}" - REQUIRE_GT=false - fi - - # Fetch all open milestones. Milestone titles may have an optional - # "v" prefix (e.g. "v1.15.0"); strip it before comparing so version - # sort works correctly. - MILESTONES=$(gh api "repos/${REPO}/milestones?state=open&per_page=100" \ - --jq '[.[] | {number: .number, title: .title, version: (.title | ltrimstr("v"))}]') - - MILESTONE_NUMBER="" - MILESTONE_TITLE="" - - # Sort candidates by version ascending and pick the first eligible one. - while IFS= read -r entry; do - number=$(echo "$entry" | jq -r '.number') - title=$(echo "$entry" | jq -r '.title') - version=$(echo "$entry" | jq -r '.version') - min=$(printf '%s\n' "${BASE_VERSION}" "${version}" | sort -V | head -1) - if [ "${min}" = "${BASE_VERSION}" ]; then - if [ "${REQUIRE_GT}" = "true" ] && [ "${version}" = "${BASE_VERSION}" ]; then - continue # Skip exact match when a release branch exists. - fi - MILESTONE_NUMBER="$number" - MILESTONE_TITLE="$title" - break - fi - done < <(echo "$MILESTONES" | jq -c 'sort_by(.version | split(".") | map(tonumber)) | .[]') - - if [ -z "$MILESTONE_NUMBER" ]; then - echo "Error: no eligible open milestone found" - exit 1 - fi - - echo "Assigning milestone '${MILESTONE_TITLE}' to PR #${PR_NUMBER}" - gh api --method PATCH "repos/${REPO}/issues/${PR_NUMBER}" \ - --field "milestone=${MILESTONE_NUMBER}" - - gh pr review --approve "$PR_URL" - - # Verify the HEAD SHA has not changed since it was verified in - # the previous step. This closes the TOCTOU window where an - # attacker could force-push to the branch between verification - # and merge. - CURRENT_HEAD=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha') - if [ "$CURRENT_HEAD" != "$HEAD_SHA" ]; then - echo "::error::PR HEAD changed between verification and merge (expected: ${HEAD_SHA}, got: ${CURRENT_HEAD}). Aborting." - exit 1 - fi - - gh pr merge --squash "$PR_URL" diff --git a/.github/workflows/dependabot_milestone.yml b/.github/workflows/dependabot_milestone.yml new file mode 100644 index 0000000000..12bc8cc2f5 --- /dev/null +++ b/.github/workflows/dependabot_milestone.yml @@ -0,0 +1,85 @@ +name: Dependabot Milestone + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + contents: read + # A milestone is set on a Dependabot PR, and the write is checked against + # the pull-requests scope because the target object is a pull request, even + # though it goes through the issues REST endpoint. issues: write is not + # enough here. + pull-requests: write + +jobs: + assign-milestone: + name: assign-milestone + runs-on: ubuntu-22.04 + # Only act on PRs opened by Dependabot. pull_request_target runs in + # the base branch context, so GITHUB_TOKEN can write to pull requests, + # and the PR branch code never executes here. + if: github.event.pull_request.user.login == 'dependabot[bot]' + + steps: + - name: Assign milestone + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + # Read the base version from source to find the correct milestone. + BASE_VERSION=$(gh api "repos/${REPO}/contents/pkg/common/version/version.go" \ + --jq '.content' | base64 -d | grep -oP 'Base = "\K[^"]+') + echo "Base version: ${BASE_VERSION}" + + # Determine whether to use >= or > BASE_VERSION when searching for + # the target milestone. The release process creates the branch + # release/v{BASE_VERSION} one week before the actual release, at the + # moment the release candidate commit is picked. Once that branch + # exists, commits on main are destined for the next release, so we + # skip the BASE_VERSION milestone and target the one after it. + RELEASE_BRANCH="release/v${BASE_VERSION}" + if gh api "repos/${REPO}/branches/${RELEASE_BRANCH}" > /dev/null 2>&1; then + echo "Release branch ${RELEASE_BRANCH} exists: targeting milestone > ${BASE_VERSION}" + REQUIRE_GT=true + else + echo "Release branch ${RELEASE_BRANCH} does not exist: targeting milestone >= ${BASE_VERSION}" + REQUIRE_GT=false + fi + + # Fetch all open milestones. Milestone titles may have an optional + # "v" prefix (e.g. "v1.15.0"); strip it before comparing so version + # sort works correctly. + MILESTONES=$(gh api "repos/${REPO}/milestones?state=open&per_page=100" \ + --jq '[.[] | {number: .number, title: .title, version: (.title | ltrimstr("v"))}]') + + MILESTONE_NUMBER="" + MILESTONE_TITLE="" + + # Sort candidates by version ascending and pick the first eligible one. + while IFS= read -r entry; do + number=$(echo "$entry" | jq -r '.number') + title=$(echo "$entry" | jq -r '.title') + version=$(echo "$entry" | jq -r '.version') + min=$(printf '%s\n' "${BASE_VERSION}" "${version}" | sort -V | head -1) + if [ "${min}" = "${BASE_VERSION}" ]; then + if [ "${REQUIRE_GT}" = "true" ] && [ "${version}" = "${BASE_VERSION}" ]; then + continue # Skip exact match when a release branch exists. + fi + MILESTONE_NUMBER="$number" + MILESTONE_TITLE="$title" + break + fi + done < <(echo "$MILESTONES" | jq -c 'sort_by(.version | split(".") | map(tonumber)) | .[]') + + if [ -z "$MILESTONE_NUMBER" ]; then + echo "Error: no eligible open milestone found" + exit 1 + fi + + echo "Assigning milestone '${MILESTONE_TITLE}' to PR #${PR_NUMBER}" + gh api --method PATCH "repos/${REPO}/issues/${PR_NUMBER}" \ + --field "milestone=${MILESTONE_NUMBER}" diff --git a/.github/workflows/depsreview.yaml b/.github/workflows/depsreview.yaml index c701a38d85..baa443252d 100644 --- a/.github/workflows/depsreview.yaml +++ b/.github/workflows/depsreview.yaml @@ -12,6 +12,6 @@ jobs: steps: - name: 'Checkout Repository' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: 'Dependency Review' uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 diff --git a/.github/workflows/nightly_build.yaml b/.github/workflows/nightly_build.yaml index af29dfddd9..b9da5633c5 100644 --- a/.github/workflows/nightly_build.yaml +++ b/.github/workflows/nightly_build.yaml @@ -21,13 +21,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 with: cosign-release: v2.6.2 - name: Install regctl - uses: regclient/actions/regctl-installer@c70ad64367908075211b10dcd2ab9fad4bfa1816 # main + uses: regclient/actions/regctl-installer@9a2d4216180dbb3e2dccfa60d2dd4afd98e42ec5 # main - name: Build images run: make images - name: Log in to GHCR diff --git a/.github/workflows/pr_build.yaml b/.github/workflows/pr_build.yaml index 0ae150353a..0cbf941992 100644 --- a/.github/workflows/pr_build.yaml +++ b/.github/workflows/pr_build.yaml @@ -19,13 +19,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Setup dep cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} @@ -43,18 +43,18 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - name: Setup build tool cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .build key: ${{ runner.os }}-tools-${{ hashFiles('.go-version','Makefile') }} @@ -80,13 +80,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} @@ -104,13 +104,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} @@ -128,13 +128,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Install regctl - uses: regclient/actions/regctl-installer@c70ad64367908075211b10dcd2ab9fad4bfa1816 # main + uses: regclient/actions/regctl-installer@9a2d4216180dbb3e2dccfa60d2dd4afd98e42ec5 # main - name: Download archived images uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -146,7 +146,7 @@ jobs: - name: Build artifacts run: ./.github/workflows/scripts/build_artifacts.sh ${{ runner.os }} - name: Archive artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: binaries-linux path: ./artifacts/ @@ -162,18 +162,18 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - name: Load cached build tools - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .build key: ${{ runner.os }}-tools-${{ hashFiles('.go-version','Makefile') }} @@ -186,7 +186,7 @@ jobs: - name: Export images run: tar -czvf images.tar.gz *-image.tar - name: Archive images - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: images path: images.tar.gz @@ -202,9 +202,9 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Load cached executables - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ./bin/ key: ${{ runner.os }}-executables-${{ github.sha }} @@ -217,7 +217,7 @@ jobs: docker save spire-server-windows:latest-local spire-agent-windows:latest-local oidc-discovery-provider-windows:latest-local -o images-windows.tar gzip images-windows.tar - name: Archive images - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: images-windows path: images-windows.tar.gz @@ -230,7 +230,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - id: set-matrix name: Collect versions run: | @@ -263,7 +263,7 @@ jobs: runs-on: ubuntu-22.04-arm steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # The "upgrade" integration test needs the history to ensure # that the version number in the source code has been bumped as @@ -271,18 +271,18 @@ jobs: # fetch depth of zero. fetch-depth: 0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Install regctl - uses: regclient/actions/regctl-installer@c70ad64367908075211b10dcd2ab9fad4bfa1816 # main + uses: regclient/actions/regctl-installer@9a2d4216180dbb3e2dccfa60d2dd4afd98e42ec5 # main - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - name: Load cached build tools - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .build key: ${{ runner.os }}-tools-${{ hashFiles('.go-version','Makefile') }} @@ -330,7 +330,7 @@ jobs: test: ${{ fromJson(needs.build-matrix.outputs.test) }} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # The "upgrade" integration test needs the history to ensure # that the version number in the source code has been bumped as @@ -338,18 +338,18 @@ jobs: # fetch depth of zero. fetch-depth: 0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Install regctl - uses: regclient/actions/regctl-installer@c70ad64367908075211b10dcd2ab9fad4bfa1816 # main + uses: regclient/actions/regctl-installer@9a2d4216180dbb3e2dccfa60d2dd4afd98e42ec5 # main - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - name: Load cached build tools - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .build key: ${{ runner.os }}-tools-${{ hashFiles('.go-version','Makefile') }} @@ -393,24 +393,24 @@ jobs: shell: msys2 {0} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' cache: true - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - name: Load cached build tools - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .build key: ${{ runner.os }}-tools-${{ hashFiles('.go-version','Makefile') }} - name: Install msys2 - uses: msys2/setup-msys2@e9898307ac31d1a803454791be09ab9973336e1c # v2.31.1 + uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2.32.0 with: msystem: MINGW64 update: true @@ -445,14 +445,14 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' cache: true - name: Setup dep cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} @@ -478,24 +478,24 @@ jobs: shell: msys2 {0} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' cache: true - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - name: Setup build tool cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .build key: ${{ runner.os }}-tools-${{ hashFiles('.go-version','Makefile') }} - name: Install msys2 - uses: msys2/setup-msys2@e9898307ac31d1a803454791be09ab9973336e1c # v2.31.1 + uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2.32.0 with: msystem: MINGW64 update: true @@ -527,19 +527,19 @@ jobs: shell: msys2 {0} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' cache: true - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - name: Install msys2 - uses: msys2/setup-msys2@e9898307ac31d1a803454791be09ab9973336e1c # v2.31.1 + uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2.32.0 with: msystem: MINGW64 update: true @@ -567,24 +567,24 @@ jobs: shell: msys2 {0} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' cache: true - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - name: Load cached build tools - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .build key: ${{ runner.os }}-tools-${{ hashFiles('.go-version','Makefile') }} - name: Install msys2 - uses: msys2/setup-msys2@e9898307ac31d1a803454791be09ab9973336e1c # v2.31.1 + uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2.32.0 with: msystem: MINGW64 update: true @@ -593,14 +593,14 @@ jobs: - name: Build binaries run: make build - name: Setup executables cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ./bin/ key: ${{ runner.os }}-executables-${{ github.sha }} - name: Build artifacts run: ./.github/workflows/scripts/build_artifacts.sh ${{ runner.os }} - name: Archive artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: binaries-windows path: ./artifacts/ diff --git a/.github/workflows/release_build.yaml b/.github/workflows/release_build.yaml index 2952741abf..f5a2b47684 100644 --- a/.github/workflows/release_build.yaml +++ b/.github/workflows/release_build.yaml @@ -19,13 +19,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Setup dep cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} @@ -42,18 +42,18 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - name: Setup build tool cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .build key: ${{ runner.os }}-tools-${{ hashFiles('.go-version','Makefile') }} @@ -78,13 +78,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} @@ -101,13 +101,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} @@ -125,13 +125,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Install regctl - uses: regclient/actions/regctl-installer@c70ad64367908075211b10dcd2ab9fad4bfa1816 # main + uses: regclient/actions/regctl-installer@9a2d4216180dbb3e2dccfa60d2dd4afd98e42ec5 # main - name: Download archived images uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -143,7 +143,7 @@ jobs: - name: Build artifacts run: ./.github/workflows/scripts/build_artifacts.sh ${{ runner.os }} - name: Archive artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: binaries-linux path: ./artifacts/ @@ -158,18 +158,18 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - name: Load cached build tools - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .build key: ${{ runner.os }}-tools-${{ hashFiles('.go-version','Makefile') }} @@ -178,7 +178,7 @@ jobs: - name: Export images run: tar -czvf images.tar.gz *-image.tar - name: Archive images - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: images path: images.tar.gz @@ -193,9 +193,9 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Load cached executables - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ./bin/ key: ${{ runner.os }}-executables-${{ github.sha }} @@ -208,7 +208,7 @@ jobs: docker save spire-server-windows:latest-local spire-agent-windows:latest-local oidc-discovery-provider-windows:latest-local -o images-windows.tar gzip images-windows.tar - name: Archive images - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: images-windows path: images-windows.tar.gz @@ -221,7 +221,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - id: set-matrix name: Collect versions run: | @@ -254,7 +254,7 @@ jobs: runs-on: ubuntu-22.04-arm steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # The "upgrade" integration test needs the history to ensure # that the version number in the source code has been bumped as @@ -271,18 +271,18 @@ jobs: - name: Fix tag annotations run: git fetch --tags --force - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Install regctl - uses: regclient/actions/regctl-installer@c70ad64367908075211b10dcd2ab9fad4bfa1816 # main + uses: regclient/actions/regctl-installer@9a2d4216180dbb3e2dccfa60d2dd4afd98e42ec5 # main - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - name: Load cached build tools - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .build key: ${{ runner.os }}-tools-${{ hashFiles('.go-version','Makefile') }} @@ -332,7 +332,7 @@ jobs: test: ${{ fromJson(needs.build-matrix.outputs.test) }} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # The "upgrade" integration test needs the history to ensure # that the version number in the source code has been bumped as @@ -340,18 +340,18 @@ jobs: # fetch depth of zero. fetch-depth: 0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Install regctl - uses: regclient/actions/regctl-installer@c70ad64367908075211b10dcd2ab9fad4bfa1816 # main + uses: regclient/actions/regctl-installer@9a2d4216180dbb3e2dccfa60d2dd4afd98e42ec5 # main - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - name: Load cached build tools - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .build key: ${{ runner.os }}-tools-${{ hashFiles('.go-version','Makefile') }} @@ -388,23 +388,23 @@ jobs: shell: msys2 {0} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - name: Load cached build tools - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .build key: ${{ runner.os }}-tools-${{ hashFiles('.go-version','Makefile') }} - name: Install msys2 - uses: msys2/setup-msys2@e9898307ac31d1a803454791be09ab9973336e1c # v2.31.1 + uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2.32.0 with: msystem: MINGW64 update: true @@ -433,13 +433,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Setup dep cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} @@ -459,23 +459,23 @@ jobs: shell: msys2 {0} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - name: Setup build tool cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .build key: ${{ runner.os }}-tools-${{ hashFiles('.go-version','Makefile') }} - name: Install msys2 - uses: msys2/setup-msys2@e9898307ac31d1a803454791be09ab9973336e1c # v2.31.1 + uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2.32.0 with: msystem: MINGW64 update: true @@ -501,18 +501,18 @@ jobs: shell: msys2 {0} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - name: Install msys2 - uses: msys2/setup-msys2@e9898307ac31d1a803454791be09ab9973336e1c # v2.31.1 + uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2.32.0 with: msystem: MINGW64 update: true @@ -534,23 +534,23 @@ jobs: shell: msys2 {0} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: 'go.mod' - name: Load cached deps - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - name: Load cached build tools - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .build key: ${{ runner.os }}-tools-${{ hashFiles('.go-version','Makefile') }} - name: Install msys2 - uses: msys2/setup-msys2@e9898307ac31d1a803454791be09ab9973336e1c # v2.31.1 + uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2.32.0 with: msystem: MINGW64 update: true @@ -561,12 +561,12 @@ jobs: - name: Build artifacts run: ./.github/workflows/scripts/build_artifacts.sh ${{ runner.os }} - name: Setup executables cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ./bin/ key: ${{ runner.os }}-executables-${{ github.sha }} - name: Archive artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: binaries-windows path: ./artifacts/ @@ -579,7 +579,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Download archived Linux artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -613,13 +613,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 with: cosign-release: v2.6.2 - name: Install regctl - uses: regclient/actions/regctl-installer@c70ad64367908075211b10dcd2ab9fad4bfa1816 # main + uses: regclient/actions/regctl-installer@9a2d4216180dbb3e2dccfa60d2dd4afd98e42ec5 # main - name: Download archived images uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/workflows/scripts/retry.sh b/.github/workflows/scripts/retry.sh new file mode 100755 index 0000000000..ec85349840 --- /dev/null +++ b/.github/workflows/scripts/retry.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +# retry runs the given command, retrying with exponential backoff if it fails. +# It is intended to harden CI steps against transient registry/network errors +# (e.g. Docker Hub image pull timeouts during a `docker buildx build`) that +# would otherwise cause spurious build failures. + +set -u + +attempts=5 +delay=5 +max_delay=60 +attempt=1 + +if [ "$#" -eq 0 ]; then + echo "retry: no command given" >&2 + exit 2 +fi + +while true; do + if "$@"; then + exit 0 + fi + if [ "${attempt}" -ge "${attempts}" ]; then + echo "retry: command failed after ${attempt} attempt(s): $*" >&2 + exit 1 + fi + echo "retry: command failed (attempt ${attempt}/${attempts}), retrying in ${delay}s: $*" >&2 + sleep "${delay}" + attempt=$((attempt + 1)) + delay=$((delay * 2)) + if [ "${delay}" -gt "${max_delay}" ]; then + delay="${max_delay}" + fi +done diff --git a/.github/workflows/scripts/split.sh b/.github/workflows/scripts/split.sh index 8f661461a9..ef77f9557e 100755 --- a/.github/workflows/scripts/split.sh +++ b/.github/workflows/scripts/split.sh @@ -15,7 +15,7 @@ fi declare -a job_set current_runner=1 for FILE in test/integration/suites/*; do - job_set[$current_runner]+="${FILE##test/integration/} " + job_set[current_runner]+="${FILE##test/integration/} " ((current_runner++)) if [ "$current_runner" -gt "$NUM_RUNNERS" ]; then diff --git a/.github/workflows/scripts/split_k8s.sh b/.github/workflows/scripts/split_k8s.sh index e3c8b28f50..bd801220a5 100755 --- a/.github/workflows/scripts/split_k8s.sh +++ b/.github/workflows/scripts/split_k8s.sh @@ -15,7 +15,7 @@ fi declare -a job_set current_runner=1 for FILE in test/integration/suites/k8s*; do - job_set[$current_runner]+="${FILE##test/integration/} " + job_set[current_runner]+="${FILE##test/integration/} " ((current_runner++)) if [ "$current_runner" -gt "$NUM_RUNNERS" ]; then diff --git a/.go-version b/.go-version index f8f7381409..ea0928cedf 100644 --- a/.go-version +++ b/.go-version @@ -1 +1 @@ -1.26.3 +1.26.4 diff --git a/CHANGELOG.md b/CHANGELOG.md index ba30cff1be..102bc7d822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # Changelog +## [1.15.2] - 2026-07-09 + +### Added + +- Support for configuring JTI claim inclusion in JWT-SVIDs at entry level (#6514) +- Experimental per-caller rate limiting for the agent Workload API and Envoy SDS (#6724) +- TLS support for the Prometheus metrics endpoint using a SPIRE SVID, with an optional SPIFFE ID allowlist (#6812) +- Optional verification of client certificate IPs in the `x509pop` node attestor (#6911) +- SPIFFE Broker endpoint, API, and documentation (#6915, #7112) +- `disable_group_name_selectors` option for the Windows workload attestor (#6957) +- `log_selectors` configuration item for the agent (#6981) +- Support for two additional PQC curves (#6999) +- Tag-based key discovery support in the `aws_kms` Key Manager plugin (#7006) +- Logger service for `spire-agent` (#7017) +- Configurable batch size for pruning attested nodes (#7100) + +### Security + +- Migrated `github.com/docker/docker` dependencies to their `github.com/moby/moby` equivalents to resolve CVEs (#7078) + +### Changed + +- Deprecation warnings now use dedicated log markers, making them easier to detect in logs (#6908) +- The OIDC Discovery Provider now warns when `allow_insecure_scheme` is enabled (#6970) +- The post-quantum cryptography policy is now applied to the bundle endpoint and Prometheus server (#6995) +- Attested nodes are now fetched in bulk, reducing database load in large deployments (#7022) +- Agent health check loopback calls no longer emit RPC metrics, reducing metrics noise (#6929) +- Pod and container IDs are now preferably determined from the cgroup file (#7060) +- Optimized the MySQL list entries query to reduce database CPU usage under load (#7113) +- Added AWS CA certificates for new regions to the `aws_iid` node attestor (#6879) +- Documented `URISanSelectors` for the agent SPIFFE ID template (#6872) +- Datastore configuration documentation updates (#7023) + +### Fixed + +- `azure_imds` node attestation for standalone VMs (#6807) +- `azure_imds` plugin signature validation (#6960) +- The auto-created join-token alias entry is now cascade-deleted when its attested node is deleted, evicted, or pruned (#6946) +- The CA journal now survives transient datastore save failures, preserving CA continuity (#6964) +- The delegated API no longer serves JWT-SVIDs for admin or downstream entries (#6972) +- The event cache now keeps previous first/last event information when reloading (#6994) +- The structured logger is now used for rebootstrap messages, and typos were fixed (#7000) +- The `spire` upstream authority plugin now validates a missing Workload API endpoint (#7008) +- The `gcp_kms` plugin no longer intermittently fails to retrieve a newly created public key (#6924) +- Tolerate `mountinfo` lines with an empty mount source (#7044) +- The agent now treats failure of all attestation plugins as an overall failure and returns `Unavailable` (#7045) +- Fixed the `http_challenge` agent name validation regex (#7066) +- Corrected TTL logging in the delegated identity X.509-SVID subscriber (#7071) +- `spire-agent` now attempts to enable `SE_DEBUG_PRIVILEGE` at startup on Windows (#7073) +- Data races in the built-in BundlePublisher plugins on dynamic reconfiguration (#7082) + ## [1.15.1] - 2026-05-28 ### Security diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83dd7f781e..b1c3bde9ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,6 +71,15 @@ In addition to the conventions covered in the SPIFFE project's [CONTRIBUTING](https://github.com/spiffe/spiffe/blob/main/CONTRIBUTING.md), the following conventions apply to the SPIRE repository: +### Pull request review process + +When you open a pull request, the description is pre-filled from our +[pull request template](.github/PULL_REQUEST_TEMPLATE.md?plain=1), which +explains how our review process works: the rotating "ball in court" assignee, +re-requesting a review after you address comments, preferring new commits over +force-pushing, and handling Copilot comments. Please follow the guidance shown +there when you open and iterate on your PR. + ### SQL Plugin Changes Datastore changes must be present in at least one full minor release cycle prior to introducing code changes that depend on them. @@ -93,12 +102,23 @@ Common functionality for agent, server, and plugins The implementation of each plugin and their support packages -`/proto/spire/{agent,server,api,common}//` +`/proto/spire/common/` and `/proto/private/` + +Protobuf `.proto` files and their generated `.pb.go` code. `/proto/spire/common/` +holds shared message definitions (package `spire.common`), with plugin-related +common types under `/proto/spire/common/plugin/`. `/proto/private/` holds internal +definitions that are not part of the public API, such as +`/proto/private/server/journal/`. -gRPC .proto files, their generated .pb.go, and README_pb.md. +Protobuf files under `/proto/spire/` use the `spire.` package namespace (e.g. +`spire.common` and `spire.common.plugin`). The go package name is specified with +the full import path, e.g. +`option go_package = "github.com/spiffe/spire/proto/spire/common";` -The protobuf package names should be `spire.{server,agent,api,common}.` and the go package name -should be specified with `option go_package = "";` +The public gRPC API and plugin interface protobufs are maintained in separate +repositories, [`spire-api-sdk`](https://github.com/spiffe/spire-api-sdk) and +[`spire-plugin-sdk`](https://github.com/spiffe/spire-plugin-sdk), rather than in +this repository. ### Interfaces diff --git a/Makefile b/Makefile index a0f35444f0..5f12c5b093 100644 --- a/Makefile +++ b/Makefile @@ -337,7 +337,8 @@ define image_rule .PHONY: $1 $1: $3 container-builder @echo Building docker image $2 $(PLATFORM)… - $(E)docker buildx build \ + $(E).github/workflows/scripts/retry.sh \ + docker buildx build \ --platform $(PLATFORMS) \ --build-arg goversion=$(go_version) \ --build-arg TAG=$(TAG) \ diff --git a/cmd/spire-agent/cli/cli.go b/cmd/spire-agent/cli/cli.go index 80fe546887..87284dec45 100644 --- a/cmd/spire-agent/cli/cli.go +++ b/cmd/spire-agent/cli/cli.go @@ -7,6 +7,7 @@ import ( "github.com/mitchellh/cli" "github.com/spiffe/spire/cmd/spire-agent/cli/api" "github.com/spiffe/spire/cmd/spire-agent/cli/healthcheck" + "github.com/spiffe/spire/cmd/spire-agent/cli/logger" "github.com/spiffe/spire/cmd/spire-agent/cli/run" "github.com/spiffe/spire/cmd/spire-agent/cli/validate" "github.com/spiffe/spire/pkg/common/log" @@ -43,6 +44,15 @@ func (cc *CLI) Run(ctx context.Context, args []string) int { "healthcheck": func() (cli.Command, error) { return healthcheck.NewHealthCheckCommand(), nil }, + "logger get": func() (cli.Command, error) { + return logger.NewGetCommand(), nil + }, + "logger set": func() (cli.Command, error) { + return logger.NewSetCommand(), nil + }, + "logger reset": func() (cli.Command, error) { + return logger.NewResetCommand(), nil + }, "validate": func() (cli.Command, error) { return validate.NewValidateCommand(), nil }, diff --git a/cmd/spire-agent/cli/logger/get.go b/cmd/spire-agent/cli/logger/get.go new file mode 100644 index 0000000000..a46929087a --- /dev/null +++ b/cmd/spire-agent/cli/logger/get.go @@ -0,0 +1,55 @@ +package logger + +import ( + "context" + "flag" + "fmt" + + "github.com/mitchellh/cli" + api "github.com/spiffe/spire-api-sdk/proto/spire/api/agent/logger/v1" + "github.com/spiffe/spire/cmd/spire-agent/util" + commonlogger "github.com/spiffe/spire/pkg/common/api/logger" + commoncli "github.com/spiffe/spire/pkg/common/cli" + "github.com/spiffe/spire/pkg/common/cliprinter" +) + +type getCommand struct { + env *commoncli.Env + printer cliprinter.Printer +} + +// Returns a cli.Command that gets the logger information using +// the default cli environment. +func NewGetCommand() cli.Command { + return NewGetCommandWithEnv(commoncli.DefaultEnv) +} + +// Returns a cli.Command that gets the root logger information. +func NewGetCommandWithEnv(env *commoncli.Env) cli.Command { + return util.AdaptCommand(env, &getCommand{env: env}) +} + +func (*getCommand) Name() string { + return "logger get" +} + +func (*getCommand) Synopsis() string { + return "Gets the logger details" +} + +func (c *getCommand) AppendFlags(fs *flag.FlagSet) { + cliprinter.AppendFlagWithCustomPretty(&c.printer, fs, c.env, c.prettyPrintLogger) +} + +func (c *getCommand) Run(ctx context.Context, _ *commoncli.Env, agentClient util.AgentClient) error { + logger, err := agentClient.NewLoggerClient().GetLogger(ctx, &api.GetLoggerRequest{}) + if err != nil { + return fmt.Errorf("error fetching logger: %w", err) + } + + return c.printer.PrintProto(logger) +} + +func (c *getCommand) prettyPrintLogger(env *commoncli.Env, results ...any) error { + return commonlogger.PrettyPrintLogger(env, results...) +} diff --git a/cmd/spire-agent/cli/logger/get_test.go b/cmd/spire-agent/cli/logger/get_test.go new file mode 100644 index 0000000000..db3ee2037f --- /dev/null +++ b/cmd/spire-agent/cli/logger/get_test.go @@ -0,0 +1,151 @@ +package logger_test + +import ( + "errors" + "testing" + + "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + "github.com/spiffe/spire/cmd/spire-agent/cli/logger" + "github.com/stretchr/testify/require" +) + +func TestGetHelp(t *testing.T) { + test := setupCliTest(t, &mockLoggerService{}, logger.NewGetCommandWithEnv) + test.client.Help() + require.Equal(t, "", test.stdout.String()) + require.Equal(t, getUsage, test.stderr.String()) +} + +func TestGetSynopsis(t *testing.T) { + cmd := logger.NewGetCommand() + require.Equal(t, "Gets the logger details", cmd.Synopsis()) +} + +func TestGet(t *testing.T) { + for _, tt := range []struct { + name string + // server state + server *mockLoggerService + // input + args []string + // expected items + expectReturnCode int + expectStdout string + expectStderr string + }{ + { + name: "configured to info, set to info, using pretty output", + args: []string{"-output", "pretty"}, + server: &mockLoggerService{ + returnLogger: &types.Logger{ + CurrentLevel: types.LogLevel_INFO, + LaunchLevel: types.LogLevel_INFO, + }, + }, + expectReturnCode: 0, + expectStdout: `Logger Level : info +Launch Level : info + +`, + }, + { + name: "configured to debug, set to warn, using pretty output", + args: []string{"-output", "pretty"}, + server: &mockLoggerService{ + returnLogger: &types.Logger{ + CurrentLevel: types.LogLevel_WARN, + LaunchLevel: types.LogLevel_DEBUG, + }, + }, + expectReturnCode: 0, + expectStdout: `Logger Level : warning +Launch Level : debug + +`, + }, + { + name: "configured to error, set to trace, using pretty output", + args: []string{"-output", "pretty"}, + server: &mockLoggerService{ + returnLogger: &types.Logger{ + CurrentLevel: types.LogLevel_TRACE, + LaunchLevel: types.LogLevel_ERROR, + }, + }, + expectReturnCode: 0, + expectStdout: `Logger Level : trace +Launch Level : error + +`, + }, + { + name: "configured to panic, set to fatal, using pretty output", + args: []string{"-output", "pretty"}, + server: &mockLoggerService{ + returnLogger: &types.Logger{ + CurrentLevel: types.LogLevel_FATAL, + LaunchLevel: types.LogLevel_PANIC, + }, + }, + expectReturnCode: 0, + expectStdout: `Logger Level : fatal +Launch Level : panic + +`, + }, + { + name: "configured to info, set to info, using json output", + args: []string{"-output", "json"}, + server: &mockLoggerService{ + returnLogger: &types.Logger{ + CurrentLevel: types.LogLevel_INFO, + LaunchLevel: types.LogLevel_INFO, + }, + }, + expectReturnCode: 0, + expectStdout: `{"current_level":"INFO","launch_level":"INFO"} +`, + }, + { + name: "configured to debug, set to warn, using json output", + args: []string{"-output", "json"}, + server: &mockLoggerService{ + returnLogger: &types.Logger{ + CurrentLevel: types.LogLevel_WARN, + LaunchLevel: types.LogLevel_DEBUG, + }, + }, + expectReturnCode: 0, + expectStdout: `{"current_level":"WARN","launch_level":"DEBUG"} +`, + }, + { + name: "server will error", + args: []string{"-output", "pretty"}, + server: &mockLoggerService{ + returnErr: errors.New("server is unavailable"), + }, + expectReturnCode: 1, + expectStderr: `Error: error fetching logger: rpc error: code = Unknown desc = server is unavailable +`, + }, + { + name: "bizzarro world, returns neither logger nor error", + args: []string{"-output", "pretty"}, + server: &mockLoggerService{ + returnLogger: nil, + }, + expectReturnCode: 1, + expectStderr: `Error: internal error: returned current log level is undefined; please report this as a bug +`, + }, + } { + t.Run(tt.name, func(t *testing.T) { + test := setupCliTest(t, tt.server, logger.NewGetCommandWithEnv) + returnCode := test.client.Run(append(test.args, tt.args...)) + require.Equal(t, tt.expectStdout, test.stdout.String()) + require.Equal(t, tt.expectStderr, test.stderr.String()) + require.Equal(t, tt.expectReturnCode, returnCode) + }) + } +} diff --git a/cmd/spire-agent/cli/logger/logger_posix_test.go b/cmd/spire-agent/cli/logger/logger_posix_test.go new file mode 100644 index 0000000000..2110b973bf --- /dev/null +++ b/cmd/spire-agent/cli/logger/logger_posix_test.go @@ -0,0 +1,31 @@ +//go:build !windows + +package logger_test + +const ( + addrArg = "-socketPath" + defaultPath = "/tmp/spire-agent/private/admin.sock" +) + +var ( + getUsage = `Usage of logger get: + -output value + Desired output format (pretty, json); default: pretty. + -socketPath string + Path to the SPIRE Agent admin API socket (default "/tmp/spire-agent/private/admin.sock") +` + setUsage = `Usage of logger set: + -level string + The new log level, one of (panic, fatal, error, warn, info, debug, trace) + -output value + Desired output format (pretty, json); default: pretty. + -socketPath string + Path to the SPIRE Agent admin API socket (default "/tmp/spire-agent/private/admin.sock") +` + resetUsage = `Usage of logger reset: + -output value + Desired output format (pretty, json); default: pretty. + -socketPath string + Path to the SPIRE Agent admin API socket (default "/tmp/spire-agent/private/admin.sock") +` +) diff --git a/cmd/spire-agent/cli/logger/logger_windows_test.go b/cmd/spire-agent/cli/logger/logger_windows_test.go new file mode 100644 index 0000000000..c0e6f3f675 --- /dev/null +++ b/cmd/spire-agent/cli/logger/logger_windows_test.go @@ -0,0 +1,31 @@ +//go:build windows + +package logger_test + +const ( + addrArg = "-namedPipeName" + defaultPath = "\\spire-agent\\private\\admin" +) + +var ( + getUsage = `Usage of logger get: + -namedPipeName string + Pipe name of the SPIRE Agent admin API named pipe (default "\\spire-agent\\private\\admin") + -output value + Desired output format (pretty, json); default: pretty. +` + setUsage = `Usage of logger set: + -level string + The new log level, one of (panic, fatal, error, warn, info, debug, trace) + -namedPipeName string + Pipe name of the SPIRE Agent admin API named pipe (default "\\spire-agent\\private\\admin") + -output value + Desired output format (pretty, json); default: pretty. +` + resetUsage = `Usage of logger reset: + -namedPipeName string + Pipe name of the SPIRE Agent admin API named pipe (default "\\spire-agent\\private\\admin") + -output value + Desired output format (pretty, json); default: pretty. +` +) diff --git a/cmd/spire-agent/cli/logger/mocks_test.go b/cmd/spire-agent/cli/logger/mocks_test.go new file mode 100644 index 0000000000..b3f7232889 --- /dev/null +++ b/cmd/spire-agent/cli/logger/mocks_test.go @@ -0,0 +1,89 @@ +package logger_test + +import ( + "bytes" + "context" + "testing" + + "github.com/mitchellh/cli" + loggerv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/agent/logger/v1" + "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + commoncli "github.com/spiffe/spire/pkg/common/cli" + "github.com/spiffe/spire/test/clitest" + "github.com/spiffe/spire/test/spiretest" + "google.golang.org/grpc" +) + +// an input/output capture struct +type loggerTest struct { + stdin *bytes.Buffer + stdout *bytes.Buffer + stderr *bytes.Buffer + args []string + server *mockLoggerService + client cli.Command +} + +// serialization of capture +func (l *loggerTest) afterTest(t *testing.T) { + t.Logf("TEST:%s", t.Name()) + t.Logf("STDOUT:\n%s", l.stdout.String()) + t.Logf("STDIN:\n%s", l.stdin.String()) + t.Logf("STDERR:\n%s", l.stderr.String()) +} + +// setup of input/output capture +func setupCliTest(t *testing.T, server *mockLoggerService, newClient func(*commoncli.Env) cli.Command) *loggerTest { + addr := spiretest.StartGRPCServer(t, func(s *grpc.Server) { + loggerv1.RegisterLoggerServer(s, server) + }) + + stdin := new(bytes.Buffer) + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + + client := newClient(&commoncli.Env{ + Stdin: stdin, + Stdout: stdout, + Stderr: stderr, + }) + + test := &loggerTest{ + stdin: stdin, + stdout: stdout, + stderr: stderr, + args: []string{addrArg, clitest.GetAddr(addr)}, + server: server, + client: client, + } + + t.Cleanup(func() { + test.afterTest(t) + }) + + return test +} + +// a mock grpc logger server +type mockLoggerService struct { + loggerv1.UnimplementedLoggerServer + + receivedSetValue *types.LogLevel + returnLogger *types.Logger + returnErr error +} + +// mock implementation for GetLogger +func (s *mockLoggerService) GetLogger(context.Context, *loggerv1.GetLoggerRequest) (*types.Logger, error) { + return s.returnLogger, s.returnErr +} + +func (s *mockLoggerService) SetLogLevel(_ context.Context, req *loggerv1.SetLogLevelRequest) (*types.Logger, error) { + s.receivedSetValue = &req.NewLevel + return s.returnLogger, s.returnErr +} + +func (s *mockLoggerService) ResetLogLevel(context.Context, *loggerv1.ResetLogLevelRequest) (*types.Logger, error) { + s.receivedSetValue = nil + return s.returnLogger, s.returnErr +} diff --git a/cmd/spire-agent/cli/logger/reset.go b/cmd/spire-agent/cli/logger/reset.go new file mode 100644 index 0000000000..ea7f75012e --- /dev/null +++ b/cmd/spire-agent/cli/logger/reset.go @@ -0,0 +1,53 @@ +package logger + +import ( + "context" + "flag" + "fmt" + + "github.com/mitchellh/cli" + api "github.com/spiffe/spire-api-sdk/proto/spire/api/agent/logger/v1" + "github.com/spiffe/spire/cmd/spire-agent/util" + commonlogger "github.com/spiffe/spire/pkg/common/api/logger" + commoncli "github.com/spiffe/spire/pkg/common/cli" + "github.com/spiffe/spire/pkg/common/cliprinter" +) + +type resetCommand struct { + env *commoncli.Env + printer cliprinter.Printer +} + +// Returns a cli.Command that resets the log level using the default cli environment. +func NewResetCommand() cli.Command { + return NewResetCommandWithEnv(commoncli.DefaultEnv) +} + +// Returns a cli.Command that resets the log level to the launch level. +func NewResetCommandWithEnv(env *commoncli.Env) cli.Command { + return util.AdaptCommand(env, &resetCommand{env: env}) +} + +func (*resetCommand) Name() string { + return "logger reset" +} + +func (*resetCommand) Synopsis() string { + return "Reset the logger details to launch level" +} + +func (c *resetCommand) AppendFlags(fs *flag.FlagSet) { + cliprinter.AppendFlagWithCustomPretty(&c.printer, fs, c.env, c.prettyPrintLogger) +} + +func (c *resetCommand) Run(ctx context.Context, _ *commoncli.Env, agentClient util.AgentClient) error { + logger, err := agentClient.NewLoggerClient().ResetLogLevel(ctx, &api.ResetLogLevelRequest{}) + if err != nil { + return fmt.Errorf("failed to reset logger: %w", err) + } + return c.printer.PrintProto(logger) +} + +func (c *resetCommand) prettyPrintLogger(env *commoncli.Env, results ...any) error { + return commonlogger.PrettyPrintLogger(env, results...) +} diff --git a/cmd/spire-agent/cli/logger/reset_test.go b/cmd/spire-agent/cli/logger/reset_test.go new file mode 100644 index 0000000000..da1f54ff60 --- /dev/null +++ b/cmd/spire-agent/cli/logger/reset_test.go @@ -0,0 +1,69 @@ +package logger_test + +import ( + "testing" + + "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + "github.com/spiffe/spire/cmd/spire-agent/cli/logger" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestResetHelp(t *testing.T) { + test := setupCliTest(t, &mockLoggerService{}, logger.NewResetCommandWithEnv) + test.client.Help() + require.Equal(t, "", test.stdout.String()) + require.Equal(t, resetUsage, test.stderr.String()) +} + +func TestResetSynopsis(t *testing.T) { + cmd := logger.NewResetCommand() + require.Equal(t, "Reset the logger details to launch level", cmd.Synopsis()) +} + +func TestReset(t *testing.T) { + for _, tt := range []struct { + name string + args []string + service *mockLoggerService + + expectReturnCode int + expectStdout string + expectStderr string + }{ + { + name: "reset successfully", + args: []string{"-output", "pretty"}, + service: &mockLoggerService{ + returnLogger: &types.Logger{ + CurrentLevel: types.LogLevel_INFO, + LaunchLevel: types.LogLevel_INFO, + }, + }, + expectReturnCode: 0, + expectStdout: `Logger Level : info +Launch Level : info + +`, + }, + { + name: "service failed", + args: []string{"-output", "pretty"}, + service: &mockLoggerService{ + returnErr: status.Error(codes.Internal, "oh no"), + }, + expectReturnCode: 1, + expectStderr: `Error: failed to reset logger: rpc error: code = Internal desc = oh no +`, + }, + } { + t.Run(tt.name, func(t *testing.T) { + test := setupCliTest(t, tt.service, logger.NewResetCommandWithEnv) + returnCode := test.client.Run(append(test.args, tt.args...)) + require.Equal(t, tt.expectReturnCode, returnCode) + require.Equal(t, tt.expectStderr, test.stderr.String()) + require.Equal(t, tt.expectStdout, test.stdout.String()) + }) + } +} diff --git a/cmd/spire-agent/cli/logger/set.go b/cmd/spire-agent/cli/logger/set.go new file mode 100644 index 0000000000..c402d889a9 --- /dev/null +++ b/cmd/spire-agent/cli/logger/set.go @@ -0,0 +1,75 @@ +package logger + +import ( + "context" + "errors" + "flag" + "fmt" + "strings" + + "github.com/mitchellh/cli" + "github.com/sirupsen/logrus" + api "github.com/spiffe/spire-api-sdk/proto/spire/api/agent/logger/v1" + "github.com/spiffe/spire/cmd/spire-agent/util" + commonlogger "github.com/spiffe/spire/pkg/common/api/logger" + commoncli "github.com/spiffe/spire/pkg/common/cli" + "github.com/spiffe/spire/pkg/common/cliprinter" +) + +type setCommand struct { + env *commoncli.Env + newLevel string + printer cliprinter.Printer +} + +// Returns a cli.Command that sets the log level using the default cli environment. +func NewSetCommand() cli.Command { + return NewSetCommandWithEnv(commoncli.DefaultEnv) +} + +// Returns a cli.Command that sets the log level. +func NewSetCommandWithEnv(env *commoncli.Env) cli.Command { + return util.AdaptCommand(env, &setCommand{env: env}) +} + +func (*setCommand) Name() string { + return "logger set" +} + +func (*setCommand) Synopsis() string { + return "Sets the logger details" +} + +func (c *setCommand) AppendFlags(fs *flag.FlagSet) { + fs.StringVar(&c.newLevel, "level", "", "The new log level, one of (panic, fatal, error, warn, info, debug, trace)") + cliprinter.AppendFlagWithCustomPretty(&c.printer, fs, c.env, c.prettyPrintLogger) +} + +func (c *setCommand) Run(ctx context.Context, _ *commoncli.Env, agentClient util.AgentClient) error { + if c.newLevel == "" { + return errors.New("a value (-level) must be set") + } + + level := strings.ToLower(c.newLevel) + logrusLevel, err := logrus.ParseLevel(level) + if err != nil { + return fmt.Errorf("the value %q is not a valid setting", c.newLevel) + } + + apiLevel, found := commonlogger.APILevel[logrusLevel] + if !found { + return fmt.Errorf("the logrus level %q could not be transformed into an api log level", level) + } + logger, err := agentClient.NewLoggerClient().SetLogLevel(ctx, &api.SetLogLevelRequest{ + NewLevel: apiLevel, + }) + if err != nil { + return fmt.Errorf("failed to set log level: %w", err) + } + + return c.printer.PrintProto(logger) +} + +func (c *setCommand) prettyPrintLogger(env *commoncli.Env, results ...any) error { + return commonlogger.PrettyPrintLogger(env, results...) +} diff --git a/cmd/spire-agent/cli/logger/set_test.go b/cmd/spire-agent/cli/logger/set_test.go new file mode 100644 index 0000000000..3b60b2191b --- /dev/null +++ b/cmd/spire-agent/cli/logger/set_test.go @@ -0,0 +1,134 @@ +package logger_test + +import ( + "testing" + + "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + "github.com/spiffe/spire/cmd/spire-agent/cli/logger" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestSetHelp(t *testing.T) { + test := setupCliTest(t, &mockLoggerService{}, logger.NewSetCommandWithEnv) + test.client.Help() + require.Equal(t, "", test.stdout.String()) + require.Equal(t, setUsage, test.stderr.String()) +} + +func TestSetSynopsis(t *testing.T) { + cmd := logger.NewSetCommand() + require.Equal(t, "Sets the logger details", cmd.Synopsis()) +} + +func TestSet(t *testing.T) { + for _, tt := range []struct { + name string + // service state + service *mockLoggerService + // input + args []string + // expected items + expectedSetValue types.LogLevel + expectReturnCode int + expectStdout string + expectStderr string + }{ + { + name: "set to debug, configured to info, using pretty output", + args: []string{"-level", "debug", "-output", "pretty"}, + service: &mockLoggerService{ + returnLogger: &types.Logger{ + CurrentLevel: types.LogLevel_DEBUG, + LaunchLevel: types.LogLevel_INFO, + }, + }, + expectedSetValue: types.LogLevel_DEBUG, + expectReturnCode: 0, + expectStdout: `Logger Level : debug +Launch Level : info + +`, + }, + { + name: "set to warn, configured to debug, using pretty output", + args: []string{"-level", "warn", "-output", "pretty"}, + service: &mockLoggerService{ + returnLogger: &types.Logger{ + CurrentLevel: types.LogLevel_WARN, + LaunchLevel: types.LogLevel_DEBUG, + }, + }, + expectedSetValue: types.LogLevel_WARN, + expectReturnCode: 0, + expectStdout: `Logger Level : warning +Launch Level : debug + +`, + }, + { + name: "set to panic, configured to fatal, using pretty output", + args: []string{"-level", "panic", "-output", "pretty"}, + service: &mockLoggerService{ + returnLogger: &types.Logger{ + CurrentLevel: types.LogLevel_PANIC, + LaunchLevel: types.LogLevel_FATAL, + }, + }, + expectedSetValue: types.LogLevel_PANIC, + expectReturnCode: 0, + expectStdout: `Logger Level : panic +Launch Level : fatal + +`, + }, + { + name: "set with invalid setting", + args: []string{"-level", "never", "-output", "pretty"}, + service: &mockLoggerService{ + returnLogger: &types.Logger{ + CurrentLevel: types.LogLevel_INFO, + LaunchLevel: types.LogLevel_INFO, + }, + }, + expectReturnCode: 1, + expectStderr: `Error: the value "never" is not a valid setting +`, + }, + { + name: "no level flag set", + args: []string{"-output", "pretty"}, + service: &mockLoggerService{ + returnLogger: &types.Logger{ + CurrentLevel: types.LogLevel_INFO, + LaunchLevel: types.LogLevel_INFO, + }, + }, + expectReturnCode: 1, + expectStderr: `Error: a value (-level) must be set +`, + }, + { + name: "service failed to set", + args: []string{"-level", "trace", "-output", "pretty"}, + service: &mockLoggerService{ + returnErr: status.Error(codes.Internal, "oh no"), + }, + expectReturnCode: 1, + expectStderr: `Error: failed to set log level: rpc error: code = Internal desc = oh no +`, + }, + } { + t.Run(tt.name, func(t *testing.T) { + test := setupCliTest(t, tt.service, logger.NewSetCommandWithEnv) + returnCode := test.client.Run(append(test.args, tt.args...)) + require.Equal(t, tt.expectReturnCode, returnCode) + if tt.expectReturnCode == 0 { + require.Equal(t, tt.expectedSetValue, *test.server.receivedSetValue) + } + require.Equal(t, tt.expectStderr, test.stderr.String()) + require.Equal(t, tt.expectStdout, test.stdout.String()) + }) + } +} diff --git a/cmd/spire-agent/cli/run/run.go b/cmd/spire-agent/cli/run/run.go index c6df70bf43..b5165ced04 100644 --- a/cmd/spire-agent/cli/run/run.go +++ b/cmd/spire-agent/cli/run/run.go @@ -17,14 +17,16 @@ import ( "syscall" "time" + "dario.cat/mergo" "github.com/google/go-cmp/cmp" "github.com/hashicorp/hcl" "github.com/hashicorp/hcl/hcl/ast" "github.com/hashicorp/hcl/hcl/token" - "github.com/imdario/mergo" "github.com/mitchellh/cli" "github.com/sirupsen/logrus" + "github.com/spiffe/go-spiffe/v2/spiffeid" "github.com/spiffe/spire/pkg/agent" + "github.com/spiffe/spire/pkg/agent/broker" "github.com/spiffe/spire/pkg/agent/client" "github.com/spiffe/spire/pkg/agent/trustbundlesources" "github.com/spiffe/spire/pkg/agent/workloadkey" @@ -75,6 +77,7 @@ type agentConfig struct { LogFile string `hcl:"log_file"` LogFormat string `hcl:"log_format"` LogLevel string `hcl:"log_level"` + LogSelectors []string `hcl:"log_selectors"` LogSourceLocation bool `hcl:"log_source_location"` SDS sdsConfig `hcl:"sds"` ServerAddress string `hcl:"server_address"` @@ -107,6 +110,117 @@ type agentConfig struct { UnusedKeyPositions map[string][]token.Pos `hcl:",unusedKeyPositions"` } +// brokerHCLConfig is the HCL block for the SPIFFE Broker API endpoint: +// +// broker { +// # At least one of these MUST be set; both MAY be set to expose the +// # broker endpoint over both transports simultaneously (e.g., UDS +// # for a local sidecar broker AND TCP for a remote broker). +// socket_path = "/run/spire/agent/broker.sock" # POSIX-only UDS +// bind_address = "0.0.0.0:8443" # TCP, both platforms +// +// brokers = [ +// { +// id = "spiffe://example.org/some/broker" +// allowed_reference_types = [ +// { +// type_url = "type.googleapis.com/spiffe.broker.KubernetesObjectReference" +// allow_over_tcp = true +// }, +// ] +// }, +// ] +// } +type brokerHCLConfig struct { + // SocketPath binds the broker endpoint to a Unix domain socket at the + // given path. POSIX-only. + SocketPath string `hcl:"socket_path"` + + // BindAddress binds the broker endpoint to a TCP address ("host:port"). + // Works on both POSIX and Windows. + BindAddress string `hcl:"bind_address"` + + // Brokers enumerates the brokers authorized to talk to this agent's + // broker endpoint. Each entry's `id` is any valid SPIFFE ID; cross- + // trust-domain broker identities are allowed. + Brokers []brokerHCLEntry `hcl:"brokers"` + + UnusedKeyPositions map[string][]token.Pos `hcl:",unusedKeyPositions"` +} + +type brokerHCLEntry struct { + ID string `hcl:"id"` + + // AllowedReferenceTypes restricts which WorkloadReference types this + // broker may use. Each entry's type_url is the verbatim protobuf type + // URL that the workload attestor plugin inspects. Use `"*"` to allow + // any reference type. Must list at least one entry. + AllowedReferenceTypes []brokerAllowedReferenceTypeHCLEntry `hcl:"allowed_reference_types"` + + UnusedKeyPositions map[string][]token.Pos `hcl:",unusedKeyPositions"` +} + +type brokerAllowedReferenceTypeHCLEntry struct { + // TypeURL is the verbatim protobuf type URL the workload attestor plugin + // inspects. The wildcard `"*"` allows any reference type. + TypeURL string `hcl:"type_url"` + + // AllowOverTCP permits this reference type over the TCP listener. The + // default is false, so the reference type is only allowed over UDS. + AllowOverTCP bool `hcl:"allow_over_tcp"` + + UnusedKeyPositions map[string][]token.Pos `hcl:",unusedKeyPositions"` +} + +// brokerBindAddrs resolves the broker endpoint's listener addresses from +// the HCL block. Returns one address per configured transport (TCP via +// `bind_address`, UDS via `socket_path`). Both MAY be set; at least one +// MUST be set. The TCP branch is platform-agnostic and resolved here; the +// UDS branch delegates to `brokerSocketAddr` which is defined per platform +// (POSIX has UDS support; Windows rejects with an error). +func (c *agentConfig) brokerBindAddrs() ([]net.Addr, error) { + sp, ba := c.Experimental.Broker.SocketPath, c.Experimental.Broker.BindAddress + if sp == "" && ba == "" { + return nil, errors.New("experimental.broker requires socket_path, bind_address, or both") + } + var addrs []net.Addr + if sp != "" { + uds, err := c.brokerSocketAddr() + if err != nil { + return nil, err + } + addrs = append(addrs, uds) + } + if ba != "" { + tcp, err := net.ResolveTCPAddr("tcp", ba) + if err != nil { + return nil, fmt.Errorf("invalid experimental.broker.bind_address %q: %w", ba, err) + } + addrs = append(addrs, tcp) + } + return addrs, nil +} + +func brokerAllowedReferenceTypesFromHCL(brokerID string, in []brokerAllowedReferenceTypeHCLEntry) ([]broker.AllowedReferenceType, error) { + if len(in) == 0 { + return nil, fmt.Errorf("experimental.broker.brokers[%s].allowed_reference_types: must list at least one reference type URL", brokerID) + } + out := make([]broker.AllowedReferenceType, 0, len(in)) + for i, allowed := range in { + if allowed.TypeURL == "" { + return nil, fmt.Errorf("experimental.broker.brokers[%s].allowed_reference_types[%d].type_url: must be specified", brokerID, i) + } + if allowed.TypeURL == "*" && len(in) != 1 { + return nil, fmt.Errorf("experimental.broker.brokers[%s].allowed_reference_types: wildcard \"*\" must be the only allowed reference type", brokerID) + } + out = append(out, broker.AllowedReferenceType{ + TypeURL: allowed.TypeURL, + AllowOverTCP: allowed.AllowOverTCP, + }) + } + return out, nil +} + type sdsConfig struct { DefaultSVIDName string `hcl:"default_svid_name"` DefaultBundleName string `hcl:"default_bundle_name"` @@ -114,6 +228,17 @@ type sdsConfig struct { DisableSPIFFECertValidation bool `hcl:"disable_spiffe_cert_validation"` } +type workloadAPIRateLimitConfig struct { + FetchX509SVID *int `hcl:"fetch_x509_svid"` + FetchJWTSVID *int `hcl:"fetch_jwt_svid"` + FetchX509Bundles *int `hcl:"fetch_x509_bundles"` + FetchJWTBundles *int `hcl:"fetch_jwt_bundles"` + StreamSecrets *int `hcl:"stream_secrets"` + FetchSecrets *int `hcl:"fetch_secrets"` + + UnusedKeyPositions map[string][]token.Pos `hcl:",unusedKeyPositions"` +} + type experimentalConfig struct { SyncInterval string `hcl:"sync_interval"` JWTSVIDCacheHitTimeout string `hcl:"jwt_svid_cache_hit_timeout"` @@ -122,6 +247,14 @@ type experimentalConfig struct { UseSyncAuthorizedEntries *bool `hcl:"use_sync_authorized_entries"` RequirePQKEM bool `hcl:"require_pq_kem"` + RateLimit workloadAPIRateLimitConfig `hcl:"ratelimit"` + + // Broker holds the configuration for the SPIFFE Broker API endpoint + // (distinct from the Delegated Identity API's authorized_delegates). + // Kept under `experimental` while the spec stabilizes — breaking + // changes may land before this moves to the top-level config. + Broker *brokerHCLConfig `hcl:"broker"` + Flags fflag.RawConfig `hcl:"feature_flags"` } @@ -512,6 +645,7 @@ func NewAgentConfig(c *Config, logOptions []log.Option, allowUnknownConfig bool) } ac.AdminBindAddress = adminAddr } + // Handle join token - read from file if specified if c.Agent.JoinTokenFile != "" { tokenBytes, err := os.ReadFile(c.Agent.JoinTokenFile) @@ -561,6 +695,7 @@ func NewAgentConfig(c *Config, logOptions []log.Option, allowUnknownConfig bool) ac.ProfilingFreq = c.Agent.ProfilingFreq ac.ProfilingNames = c.Agent.ProfilingNames + ac.LogSelectors = c.Agent.LogSelectors ac.AllowedForeignJWTClaims = c.Agent.AllowedForeignJWTClaims ac.PluginConfigs, err = catalog.PluginConfigsFromHCLNode(c.Plugins) @@ -587,6 +722,48 @@ func NewAgentConfig(c *Config, logOptions []log.Option, allowUnknownConfig bool) ac.AuthorizedDelegates = c.Agent.AuthorizedDelegates + if c.Agent.Experimental.Broker != nil { + bindAddrs, err := c.Agent.brokerBindAddrs() + if err != nil { + return nil, err + } + // Pass 1: validate every entry has a non-empty, unique ID. Doing + // this first means subsequent errors can reference brokers by ID + // instead of slice index — which is more helpful to operators + // since they wrote the IDs themselves. + seen := make(map[string]struct{}, len(c.Agent.Experimental.Broker.Brokers)) + for i, b := range c.Agent.Experimental.Broker.Brokers { + if b.ID == "" { + return nil, fmt.Errorf("experimental.broker.brokers[%d].id: must be specified", i) + } + if _, dup := seen[b.ID]; dup { + return nil, fmt.Errorf("experimental.broker.brokers[%s].id: duplicate broker id", b.ID) + } + seen[b.ID] = struct{}{} + } + + // Pass 2: per-broker field validation. ID is referenced in error + // messages now that we know it's set and unique. + brokers := make([]broker.Broker, 0, len(c.Agent.Experimental.Broker.Brokers)) + for _, b := range c.Agent.Experimental.Broker.Brokers { + if _, err := spiffeid.FromString(b.ID); err != nil { + return nil, fmt.Errorf("experimental.broker.brokers[%s].id: %w", b.ID, err) + } + allowedReferenceTypes, err := brokerAllowedReferenceTypesFromHCL(b.ID, b.AllowedReferenceTypes) + if err != nil { + return nil, err + } + brokers = append(brokers, broker.Broker{ + ID: b.ID, + AllowedReferenceTypes: allowedReferenceTypes, + }) + } + ac.Broker = agent.BrokerConfig{ + BindAddresses: bindAddrs, + Brokers: brokers, + } + } + if c.Agent.AvailabilityTarget != "" { t, err := time.ParseDuration(c.Agent.AvailabilityTarget) if err != nil { @@ -604,6 +781,39 @@ func NewAgentConfig(c *Config, logOptions []log.Option, allowUnknownConfig bool) tlspolicy.LogPolicy(ac.TLSPolicy, log.NewHCLogAdapter(logger, "tlspolicy")) + intVal := func(p *int) int { + if p == nil { + return 0 + } + return *p + } + ac.WorkloadAPIRateLimit = agent.WorkloadAPIRateLimitConfig{ + FetchX509SVID: intVal(c.Agent.Experimental.RateLimit.FetchX509SVID), + FetchJWTSVID: intVal(c.Agent.Experimental.RateLimit.FetchJWTSVID), + FetchX509Bundles: intVal(c.Agent.Experimental.RateLimit.FetchX509Bundles), + FetchJWTBundles: intVal(c.Agent.Experimental.RateLimit.FetchJWTBundles), + StreamSecrets: intVal(c.Agent.Experimental.RateLimit.StreamSecrets), + FetchSecrets: intVal(c.Agent.Experimental.RateLimit.FetchSecrets), + } + if ac.WorkloadAPIRateLimit.FetchX509SVID < 0 { + return nil, errors.New("experimental.ratelimit.fetch_x509_svid must not be negative") + } + if ac.WorkloadAPIRateLimit.FetchJWTSVID < 0 { + return nil, errors.New("experimental.ratelimit.fetch_jwt_svid must not be negative") + } + if ac.WorkloadAPIRateLimit.FetchX509Bundles < 0 { + return nil, errors.New("experimental.ratelimit.fetch_x509_bundles must not be negative") + } + if ac.WorkloadAPIRateLimit.FetchJWTBundles < 0 { + return nil, errors.New("experimental.ratelimit.fetch_jwt_bundles must not be negative") + } + if ac.WorkloadAPIRateLimit.StreamSecrets < 0 { + return nil, errors.New("experimental.ratelimit.stream_secrets must not be negative") + } + if ac.WorkloadAPIRateLimit.FetchSecrets < 0 { + return nil, errors.New("experimental.ratelimit.fetch_secrets must not be negative") + } + if cmp.Diff(experimentalConfig{}, c.Agent.Experimental) != "" { logger.Warn("Experimental features have been enabled. Please see doc/upgrading.md for upgrade and compatibility considerations for experimental features.") } @@ -646,6 +856,22 @@ func checkForUnknownConfig(c *Config, l logrus.FieldLogger) (err error) { detectedUnknown("agent", a.UnusedKeyPositions) } + if a := c.Agent; a != nil && a.Experimental.Broker != nil { + if len(a.Experimental.Broker.UnusedKeyPositions) != 0 { + detectedUnknown("experimental.broker", a.Experimental.Broker.UnusedKeyPositions) + } + for i, b := range a.Experimental.Broker.Brokers { + if len(b.UnusedKeyPositions) != 0 { + detectedUnknown(fmt.Sprintf("experimental.broker.brokers[%d]", i), b.UnusedKeyPositions) + } + for j, allowed := range b.AllowedReferenceTypes { + if len(allowed.UnusedKeyPositions) != 0 { + detectedUnknown(fmt.Sprintf("experimental.broker.brokers[%d].allowed_reference_types[%d]", i, j), allowed.UnusedKeyPositions) + } + } + } + } + // TODO: Re-enable unused key detection for telemetry. See // https://github.com/spiffe/spire/issues/1101 for more information // @@ -683,6 +909,10 @@ func checkForUnknownConfig(c *Config, l logrus.FieldLogger) (err error) { detectedUnknown("health check", c.HealthChecks.UnusedKeyPositions) } + if a := c.Agent; a != nil && len(a.Experimental.RateLimit.UnusedKeyPositions) != 0 { + detectedUnknown("ratelimit", a.Experimental.RateLimit.UnusedKeyPositions) + } + return err } diff --git a/cmd/spire-agent/cli/run/run_posix.go b/cmd/spire-agent/cli/run/run_posix.go index 4c8ddd9d03..8a63a48e97 100644 --- a/cmd/spire-agent/cli/run/run_posix.go +++ b/cmd/spire-agent/cli/run/run_posix.go @@ -53,6 +53,28 @@ func (c *agentConfig) hasAdminAddr() bool { return c.AdminSocketPath != "" } +// brokerSocketAddr resolves the UDS branch of broker bind-address selection. +// Returns the platform-agnostic TCP branch is handled in brokerBindAddr. +func (c *agentConfig) brokerSocketAddr() (net.Addr, error) { + socketPathAbs, err := filepath.Abs(c.SocketPath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path for socket_path: %w", err) + } + brokerSocketPathAbs, err := filepath.Abs(c.Experimental.Broker.SocketPath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path for experimental.broker.socket_path: %w", err) + } + + if strings.HasPrefix(brokerSocketPathAbs, filepath.Dir(socketPathAbs)+"/") { + return nil, errors.New("broker socket cannot be in the same directory or a subdirectory as that containing the Workload API socket") + } + + return &net.UnixAddr{ + Name: brokerSocketPathAbs, + Net: "unix", + }, nil +} + // validateOS performs posix specific validations of the agent config func (c *agentConfig) validateOS() error { if c.Experimental.NamedPipeName != "" { @@ -88,5 +110,19 @@ func prepareEndpoints(c *agent.Config) error { } } + for _, addr := range c.Broker.BindAddresses { + if addr.Network() != "unix" { + continue + } + // Create uds dir and parents if not exists + brokerDir := filepath.Dir(addr.String()) + if _, statErr := os.Stat(brokerDir); os.IsNotExist(statErr) { + c.Log.WithField("dir", brokerDir).Infof("Creating broker UDS directory") + if err := os.MkdirAll(brokerDir, 0755); err != nil { + return err + } + } + } + return nil } diff --git a/cmd/spire-agent/cli/run/run_test.go b/cmd/spire-agent/cli/run/run_test.go index a9426213dc..45f265e92f 100644 --- a/cmd/spire-agent/cli/run/run_test.go +++ b/cmd/spire-agent/cli/run/run_test.go @@ -13,6 +13,7 @@ import ( "github.com/sirupsen/logrus" "github.com/sirupsen/logrus/hooks/test" "github.com/spiffe/spire/pkg/agent" + agentbroker "github.com/spiffe/spire/pkg/agent/broker" "github.com/spiffe/spire/pkg/agent/client" "github.com/spiffe/spire/pkg/agent/workloadkey" "github.com/spiffe/spire/pkg/common/log" @@ -307,6 +308,24 @@ func TestMergeInput(t *testing.T) { require.Equal(t, "INFO", c.Agent.LogLevel) }, }, + { + msg: "log_selectors should default to empty if not set", + fileInput: func(c *Config) {}, + cliInput: func(c *agentConfig) {}, + test: func(t *testing.T, c *Config) { + require.Empty(t, c.Agent.LogSelectors) + }, + }, + { + msg: "log_selectors should be configurable by file", + fileInput: func(c *Config) { + c.Agent.LogSelectors = []string{"k8s:ns", "unix:user"} + }, + cliInput: func(c *agentConfig) {}, + test: func(t *testing.T, c *Config) { + require.Equal(t, []string{"k8s:ns", "unix:user"}, c.Agent.LogSelectors) + }, + }, { msg: "log_level should be configurable by file", fileInput: func(c *Config) { @@ -816,6 +835,15 @@ func TestNewAgentConfig(t *testing.T) { require.Equal(t, workloadkey.ECP256, c.WorkloadKeyType) }, }, + { + msg: "log_selectors is copied", + input: func(c *Config) { + c.Agent.LogSelectors = []string{"k8s:ns", "unix:user"} + }, + test: func(t *testing.T, c *agent.Config) { + require.Equal(t, []string{"k8s:ns", "unix:user"}, c.LogSelectors) + }, + }, { msg: "workload_key_type is set", input: func(c *Config) { @@ -1001,6 +1029,89 @@ func TestNewAgentConfig(t *testing.T) { assert.NotNil(t, c) }, }, + { + msg: "broker allowed reference types parse object entries", + input: func(c *Config) { + c.Agent.Experimental.Broker = &brokerHCLConfig{ + BindAddress: "127.0.0.1:8443", + Brokers: []brokerHCLEntry{ + { + ID: "spiffe://example.org/broker", + AllowedReferenceTypes: []brokerAllowedReferenceTypeHCLEntry{ + {TypeURL: "type.googleapis.com/spiffe.broker.KubernetesObjectReference", AllowOverTCP: true}, + {TypeURL: "type.googleapis.com/spiffe.broker.WorkloadPIDReference"}, + }, + }, + }, + } + }, + test: func(t *testing.T, c *agent.Config) { + require.Len(t, c.Broker.Brokers, 1) + require.Equal(t, []agentbroker.AllowedReferenceType{ + {TypeURL: "type.googleapis.com/spiffe.broker.KubernetesObjectReference", AllowOverTCP: true}, + {TypeURL: "type.googleapis.com/spiffe.broker.WorkloadPIDReference"}, + }, c.Broker.Brokers[0].AllowedReferenceTypes) + }, + }, + { + msg: "broker allowed_reference_types is required", + expectError: true, + requireErrorPrefix: "experimental.broker.brokers[spiffe://example.org/broker].allowed_reference_types: must list at least one reference type URL", + input: func(c *Config) { + c.Agent.Experimental.Broker = &brokerHCLConfig{ + BindAddress: "127.0.0.1:8443", + Brokers: []brokerHCLEntry{ + {ID: "spiffe://example.org/broker"}, + }, + } + }, + test: func(t *testing.T, c *agent.Config) { + require.Nil(t, c) + }, + }, + { + msg: "broker allowed reference type requires type_url", + expectError: true, + requireErrorPrefix: "experimental.broker.brokers[spiffe://example.org/broker].allowed_reference_types[0].type_url: must be specified", + input: func(c *Config) { + c.Agent.Experimental.Broker = &brokerHCLConfig{ + BindAddress: "127.0.0.1:8443", + Brokers: []brokerHCLEntry{ + { + ID: "spiffe://example.org/broker", + AllowedReferenceTypes: []brokerAllowedReferenceTypeHCLEntry{ + {}, + }, + }, + }, + } + }, + test: func(t *testing.T, c *agent.Config) { + require.Nil(t, c) + }, + }, + { + msg: "broker wildcard must be the only allowed reference type", + expectError: true, + requireErrorPrefix: "experimental.broker.brokers[spiffe://example.org/broker].allowed_reference_types: wildcard \"*\" must be the only allowed reference type", + input: func(c *Config) { + c.Agent.Experimental.Broker = &brokerHCLConfig{ + BindAddress: "127.0.0.1:8443", + Brokers: []brokerHCLEntry{ + { + ID: "spiffe://example.org/broker", + AllowedReferenceTypes: []brokerAllowedReferenceTypeHCLEntry{ + {TypeURL: "*"}, + {TypeURL: "type.googleapis.com/spiffe.broker.KubernetesObjectReference"}, + }, + }, + }, + } + }, + test: func(t *testing.T, c *agent.Config) { + require.Nil(t, c) + }, + }, { msg: "availability_target parses a duration", input: func(c *Config) { @@ -1086,6 +1197,128 @@ func TestNewAgentConfig(t *testing.T) { require.Nil(t, ac) }, }, + { + msg: "ratelimit defaults to zero (disabled)", + input: func(c *Config) { + // no ratelimit config set + }, + test: func(t *testing.T, ac *agent.Config) { + require.Equal(t, agent.WorkloadAPIRateLimitConfig{}, ac.WorkloadAPIRateLimit) + }, + }, + { + msg: "ratelimit fetch_x509_svid is configurable", + input: func(c *Config) { + v := 100 + c.Agent.Experimental.RateLimit.FetchX509SVID = &v + }, + test: func(t *testing.T, ac *agent.Config) { + require.Equal(t, 100, ac.WorkloadAPIRateLimit.FetchX509SVID) + require.Equal(t, 0, ac.WorkloadAPIRateLimit.FetchJWTSVID) + }, + }, + { + msg: "ratelimit both methods are configurable", + input: func(c *Config) { + a, d := 1, 3 + c.Agent.Experimental.RateLimit.FetchX509SVID = &a + c.Agent.Experimental.RateLimit.FetchJWTSVID = &d + }, + test: func(t *testing.T, ac *agent.Config) { + require.Equal(t, agent.WorkloadAPIRateLimitConfig{ + FetchX509SVID: 1, + FetchJWTSVID: 3, + }, ac.WorkloadAPIRateLimit) + }, + }, + { + msg: "ratelimit fetch_x509_svid negative value returns an error", + expectError: true, + input: func(c *Config) { + v := -1 + c.Agent.Experimental.RateLimit.FetchX509SVID = &v + }, + test: func(t *testing.T, ac *agent.Config) { + require.Nil(t, ac) + }, + }, + { + msg: "ratelimit fetch_jwt_svid negative value returns an error", + expectError: true, + input: func(c *Config) { + v := -1 + c.Agent.Experimental.RateLimit.FetchJWTSVID = &v + }, + test: func(t *testing.T, ac *agent.Config) { + require.Nil(t, ac) + }, + }, + { + msg: "ratelimit all six knobs are configurable", + input: func(c *Config) { + a, b, d, e, f, g := 10, 20, 30, 40, 50, 60 + c.Agent.Experimental.RateLimit.FetchX509SVID = &a + c.Agent.Experimental.RateLimit.FetchJWTSVID = &b + c.Agent.Experimental.RateLimit.FetchX509Bundles = &d + c.Agent.Experimental.RateLimit.FetchJWTBundles = &e + c.Agent.Experimental.RateLimit.StreamSecrets = &f + c.Agent.Experimental.RateLimit.FetchSecrets = &g + }, + test: func(t *testing.T, ac *agent.Config) { + require.Equal(t, agent.WorkloadAPIRateLimitConfig{ + FetchX509SVID: 10, + FetchJWTSVID: 20, + FetchX509Bundles: 30, + FetchJWTBundles: 40, + StreamSecrets: 50, + FetchSecrets: 60, + }, ac.WorkloadAPIRateLimit) + }, + }, + { + msg: "ratelimit fetch_x509_bundles negative value returns an error", + expectError: true, + input: func(c *Config) { + v := -1 + c.Agent.Experimental.RateLimit.FetchX509Bundles = &v + }, + test: func(t *testing.T, ac *agent.Config) { + require.Nil(t, ac) + }, + }, + { + msg: "ratelimit fetch_jwt_bundles negative value returns an error", + expectError: true, + input: func(c *Config) { + v := -1 + c.Agent.Experimental.RateLimit.FetchJWTBundles = &v + }, + test: func(t *testing.T, ac *agent.Config) { + require.Nil(t, ac) + }, + }, + { + msg: "ratelimit stream_secrets negative value returns an error", + expectError: true, + input: func(c *Config) { + v := -1 + c.Agent.Experimental.RateLimit.StreamSecrets = &v + }, + test: func(t *testing.T, ac *agent.Config) { + require.Nil(t, ac) + }, + }, + { + msg: "ratelimit fetch_secrets negative value returns an error", + expectError: true, + input: func(c *Config) { + v := -1 + c.Agent.Experimental.RateLimit.FetchSecrets = &v + }, + test: func(t *testing.T, ac *agent.Config) { + require.Nil(t, ac) + }, + }, } cases = append(cases, newAgentConfigCasesOS(t)...) for _, testCase := range cases { @@ -1114,6 +1347,46 @@ func TestNewAgentConfig(t *testing.T) { } } +func TestParseBrokerAllowedReferenceTypes(t *testing.T) { + file, err := os.CreateTemp("", "spire-agent-broker-*.conf") + require.NoError(t, err) + defer os.Remove(file.Name()) + + _, err = file.WriteString(` +agent { + experimental { + broker { + bind_address = "127.0.0.1:8443" + brokers = [ + { + id = "spiffe://example.org/broker" + allowed_reference_types = [ + { + type_url = "type.googleapis.com/spiffe.broker.KubernetesObjectReference" + allow_over_tcp = true + }, + { + type_url = "type.googleapis.com/spiffe.broker.WorkloadPIDReference" + }, + ] + }, + ] + } + } +} +`) + require.NoError(t, err) + require.NoError(t, file.Close()) + + c, err := ParseFile(file.Name(), false) + require.NoError(t, err) + require.NotNil(t, c.Agent.Experimental.Broker) + require.Equal(t, []brokerAllowedReferenceTypeHCLEntry{ + {TypeURL: "type.googleapis.com/spiffe.broker.KubernetesObjectReference", AllowOverTCP: true}, + {TypeURL: "type.googleapis.com/spiffe.broker.WorkloadPIDReference"}, + }, c.Agent.Experimental.Broker.Brokers[0].AllowedReferenceTypes) +} + // defaultValidConfig returns the bare minimum config required to // pass validation etc func defaultValidConfig() *Config { @@ -1248,6 +1521,16 @@ func TestWarnOnUnknownConfig(t *testing.T) { }, }, }, + { + msg: "in nested ratelimit block", + confFile: "agent_bad_nested_ratelimit_block.conf", + expectedLogEntries: []logEntry{ + { + section: "ratelimit", + keys: "unknown_option1,unknown_option2", + }, + }, + }, } for _, testCase := range cases { diff --git a/cmd/spire-agent/cli/run/run_windows.go b/cmd/spire-agent/cli/run/run_windows.go index 015bbc3420..be617fa709 100644 --- a/cmd/spire-agent/cli/run/run_windows.go +++ b/cmd/spire-agent/cli/run/run_windows.go @@ -5,11 +5,21 @@ package run import ( "errors" "flag" + "fmt" "net" + "unsafe" "github.com/spiffe/spire/cmd/spire-agent/cli/common" "github.com/spiffe/spire/pkg/agent" "github.com/spiffe/spire/pkg/common/namedpipe" + "golang.org/x/sys/windows" +) + +var ( + // We can't use the AdjustTokenPrivileges function from x/sys/windows because + // it currently does not handle the ERROR_NOT_ALL_ASSIGNED error. Based on testing + // it seems to return a nil error even if fails to adjust the privileges. + procAdjustTokenPrivileges = windows.NewLazySystemDLL("advapi32.dll").NewProc("AdjustTokenPrivileges") ) func (c *agentConfig) addOSFlags(flags *flag.FlagSet) { @@ -32,6 +42,13 @@ func (c *agentConfig) hasAdminAddr() bool { return c.Experimental.AdminNamedPipeName != "" } +// brokerSocketAddr resolves the UDS branch of broker bind-address selection. +// Windows does not support UDS for the broker endpoint; configure +// `experimental.broker.bind_address` (TCP) instead. +func (c *agentConfig) brokerSocketAddr() (net.Addr, error) { + return nil, errors.New("experimental.broker.socket_path is not supported on this platform; use experimental.broker.bind_address (TCP) instead") +} + // validateOS performs windows specific validations of the agent config func (c *agentConfig) validateOS() error { if c.SocketPath != "" { @@ -43,7 +60,53 @@ func (c *agentConfig) validateOS() error { return nil } -func prepareEndpoints(*agent.Config) error { - // Nothing to do in this platform +func prepareEndpoints(c *agent.Config) error { + if err := enableSeDebugPrivilege(); err != nil { + c.Log.WithError(err).Warn("Could not enable SeDebugPrivilege; workload attestation of processes running as more privileged users may fail") + } else { + c.Log.Info("Enabled SeDebugPrivilege") + } + return nil +} + +func enableSeDebugPrivilege() error { + var token windows.Token + err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_ADJUST_PRIVILEGES|windows.TOKEN_QUERY, &token) + if err != nil { + return fmt.Errorf("failed to open process token: %w", err) + } + defer token.Close() + + var luid windows.LUID + seDebug, err := windows.UTF16PtrFromString("SeDebugPrivilege") + if err != nil { + return fmt.Errorf("failed to encode privilege name: %w", err) + } + err = windows.LookupPrivilegeValue(nil, seDebug, &luid) + if err != nil { + return fmt.Errorf("failed to look up SeDebugPrivilege: %w", err) + } + + tp := windows.Tokenprivileges{ + PrivilegeCount: 1, + Privileges: [1]windows.LUIDAndAttributes{ + {Luid: luid, Attributes: windows.SE_PRIVILEGE_ENABLED}, + }, + } + + result, _, err := procAdjustTokenPrivileges.Call( + uintptr(token), + 0, + uintptr(unsafe.Pointer(&tp)), + 0, + 0, + 0, + ) + if result == 0 { + return fmt.Errorf("AdjustTokenPrivileges failed: %w", err) + } + if errors.Is(err, windows.ERROR_NOT_ALL_ASSIGNED) { + return errors.New("SeDebugPrivilege is not held by this token") + } return nil } diff --git a/cmd/spire-agent/util/util.go b/cmd/spire-agent/util/util.go new file mode 100644 index 0000000000..c4296556be --- /dev/null +++ b/cmd/spire-agent/util/util.go @@ -0,0 +1,110 @@ +package util + +import ( + "context" + "flag" + "fmt" + + loggerv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/agent/logger/v1" + common_cli "github.com/spiffe/spire/pkg/common/cli" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func NewGRPCClient(addr string) (*grpc.ClientConn, error) { + return grpc.NewClient( + addr, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithContextDialer(dialer), + ) +} + +type AgentClient interface { + Release() + NewLoggerClient() loggerv1.LoggerClient +} + +func NewAgentClient(addr string) (AgentClient, error) { + conn, err := NewGRPCClient(addr) + if err != nil { + return nil, err + } + return &agentClient{conn: conn}, nil +} + +type agentClient struct { + conn *grpc.ClientConn +} + +func (c *agentClient) Release() { + c.conn.Close() +} + +func (c *agentClient) NewLoggerClient() loggerv1.LoggerClient { + return loggerv1.NewLoggerClient(c.conn) +} + +// Command is a common interface for commands in this package. The adapter +// adapts this interface to the Command interface from github.com/mitchellh/cli. +type Command interface { + Name() string + Synopsis() string + AppendFlags(*flag.FlagSet) + Run(context.Context, *common_cli.Env, AgentClient) error +} + +type Adapter struct { + env *common_cli.Env + cmd Command + + flags *flag.FlagSet + + adapterOS // OS specific +} + +// AdaptCommand converts a command into one conforming to the Command interface from github.com/mitchellh/cli +func AdaptCommand(env *common_cli.Env, cmd Command) *Adapter { + a := &Adapter{ + cmd: cmd, + env: env, + } + + f := flag.NewFlagSet(cmd.Name(), flag.ContinueOnError) + f.SetOutput(env.Stderr) + a.addOSFlags(f) + a.cmd.AppendFlags(f) + a.flags = f + + return a +} + +func (a *Adapter) Run(args []string) int { + ctx := context.Background() + + if err := a.flags.Parse(args); err != nil { + return 1 + } + + addr := a.getGRPCAddr() + client, err := NewAgentClient(addr) + if err != nil { + fmt.Fprintln(a.env.Stderr, "Error: "+err.Error()) + return 1 + } + defer client.Release() + + if err := a.cmd.Run(ctx, a.env, client); err != nil { + fmt.Fprintln(a.env.Stderr, "Error: "+err.Error()) + return 1 + } + + return 0 +} + +func (a *Adapter) Help() string { + return a.flags.Parse([]string{"-h"}).Error() +} + +func (a *Adapter) Synopsis() string { + return a.cmd.Synopsis() +} diff --git a/cmd/spire-agent/util/util_posix.go b/cmd/spire-agent/util/util_posix.go new file mode 100644 index 0000000000..805b50807f --- /dev/null +++ b/cmd/spire-agent/util/util_posix.go @@ -0,0 +1,35 @@ +//go:build !windows + +package util + +import ( + "context" + "flag" + "net" + "strings" + + "github.com/spiffe/spire/cmd/spire-agent/cli/common" +) + +const DefaultAdminSocketPath = common.DefaultAdminSocketPath + +type adapterOS struct { + socketPath string +} + +func (a *Adapter) addOSFlags(flags *flag.FlagSet) { + flags.StringVar(&a.socketPath, "socketPath", DefaultAdminSocketPath, "Path to the SPIRE Agent admin API socket") +} + +func (a *Adapter) getGRPCAddr() string { + if a.socketPath == "" { + a.socketPath = DefaultAdminSocketPath + } + + return "unix:" + a.socketPath +} + +func dialer(ctx context.Context, addr string) (net.Conn, error) { + socketPathAddr := strings.TrimPrefix(addr, "unix:") + return (&net.Dialer{}).DialContext(ctx, "unix", socketPathAddr) +} diff --git a/cmd/spire-agent/util/util_windows.go b/cmd/spire-agent/util/util_windows.go new file mode 100644 index 0000000000..a88929016f --- /dev/null +++ b/cmd/spire-agent/util/util_windows.go @@ -0,0 +1,37 @@ +//go:build windows + +package util + +import ( + "context" + "flag" + "net" + "strings" + + "github.com/Microsoft/go-winio" + "github.com/spiffe/spire/cmd/spire-agent/cli/common" + "github.com/spiffe/spire/pkg/common/namedpipe" +) + +const DefaultAdminNamedPipeName = common.DefaultAdminNamedPipeName + +type adapterOS struct { + namedPipeName string +} + +func (a *Adapter) addOSFlags(flags *flag.FlagSet) { + flags.StringVar(&a.namedPipeName, "namedPipeName", DefaultAdminNamedPipeName, "Pipe name of the SPIRE Agent admin API named pipe") +} + +func dialer(ctx context.Context, addr string) (net.Conn, error) { + npipeAddr := strings.TrimPrefix(addr, "passthrough:") + return winio.DialPipeContext(ctx, npipeAddr) +} + +func (a *Adapter) getGRPCAddr() string { + if a.namedPipeName == "" { + a.namedPipeName = DefaultAdminNamedPipeName + } + + return "passthrough:" + namedpipe.AddrFromName(a.namedPipeName).String() +} diff --git a/cmd/spire-server/cli/federation/create_test.go b/cmd/spire-server/cli/federation/create_test.go index 4c7d69619b..8c38e1a01d 100644 --- a/cmd/spire-server/cli/federation/create_test.go +++ b/cmd/spire-server/cli/federation/create_test.go @@ -11,8 +11,8 @@ import ( "github.com/spiffe/go-spiffe/v2/spiffeid" trustdomainv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/server/trustdomain/v1" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + commonapi "github.com/spiffe/spire/pkg/common/api" "github.com/spiffe/spire/pkg/common/pemutil" - "github.com/spiffe/spire/pkg/server/api" "github.com/spiffe/spire/test/spiretest" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" @@ -201,7 +201,7 @@ func TestCreate(t *testing.T) { fakeResp: &trustdomainv1.BatchCreateFederationRelationshipResponse{ Results: []*trustdomainv1.BatchCreateFederationRelationshipResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: frSPIFFE, }, }, @@ -239,7 +239,7 @@ Endpoint SPIFFE ID : spiffe://other.org/bundle fakeResp: &trustdomainv1.BatchCreateFederationRelationshipResponse{ Results: []*trustdomainv1.BatchCreateFederationRelationshipResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: frSPIFFEAndBundle, }, }, @@ -290,7 +290,7 @@ Endpoint SPIFFE ID : spiffe://td-3.org/bundle fakeResp: &trustdomainv1.BatchCreateFederationRelationshipResponse{ Results: []*trustdomainv1.BatchCreateFederationRelationshipResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: frWeb, }, }, @@ -368,9 +368,9 @@ Error: failed to create one or more federation relationships }, fakeResp: &trustdomainv1.BatchCreateFederationRelationshipResponse{ Results: []*trustdomainv1.BatchCreateFederationRelationshipResponse_Result{ - {FederationRelationship: frWeb, Status: api.OK()}, - {FederationRelationship: frSPIFFE, Status: api.OK()}, - {FederationRelationship: frPemAuthority, Status: api.OK()}, + {FederationRelationship: frWeb, Status: commonapi.OK()}, + {FederationRelationship: frSPIFFE, Status: commonapi.OK()}, + {FederationRelationship: frPemAuthority, Status: commonapi.OK()}, }, }, expOutPretty: ` diff --git a/cmd/spire-server/cli/federation/delete_test.go b/cmd/spire-server/cli/federation/delete_test.go index 2c7f498a87..8713d2fe85 100644 --- a/cmd/spire-server/cli/federation/delete_test.go +++ b/cmd/spire-server/cli/federation/delete_test.go @@ -6,7 +6,7 @@ import ( trustdomainv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/server/trustdomain/v1" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" - "github.com/spiffe/spire/pkg/server/api" + commonapi "github.com/spiffe/spire/pkg/common/api" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -47,7 +47,7 @@ func TestDelete(t *testing.T) { deleteResp: &trustdomainv1.BatchDeleteFederationRelationshipResponse{ Results: []*trustdomainv1.BatchDeleteFederationRelationshipResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), TrustDomain: "example.org", }, }, diff --git a/cmd/spire-server/cli/federation/refresh.go b/cmd/spire-server/cli/federation/refresh.go index 277d7d6774..bda1265ed2 100644 --- a/cmd/spire-server/cli/federation/refresh.go +++ b/cmd/spire-server/cli/federation/refresh.go @@ -9,9 +9,9 @@ import ( "github.com/mitchellh/cli" "github.com/spiffe/spire-api-sdk/proto/spire/api/server/trustdomain/v1" "github.com/spiffe/spire/cmd/spire-server/util" + commonapi "github.com/spiffe/spire/pkg/common/api" commoncli "github.com/spiffe/spire/pkg/common/cli" "github.com/spiffe/spire/pkg/common/cliprinter" - "github.com/spiffe/spire/pkg/server/api" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -55,7 +55,7 @@ func (c *refreshCommand) Run(ctx context.Context, _ *commoncli.Env, serverClient switch status.Code(err) { case codes.OK: - return c.printer.PrintProto(api.OK()) + return c.printer.PrintProto(commonapi.OK()) case codes.NotFound: return fmt.Errorf("there is no federation relationship with trust domain %q", c.id) default: diff --git a/cmd/spire-server/cli/federation/update_test.go b/cmd/spire-server/cli/federation/update_test.go index 44fe3134e9..7e97316eb0 100644 --- a/cmd/spire-server/cli/federation/update_test.go +++ b/cmd/spire-server/cli/federation/update_test.go @@ -11,8 +11,8 @@ import ( "github.com/spiffe/go-spiffe/v2/spiffeid" trustdomainv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/server/trustdomain/v1" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + commonapi "github.com/spiffe/spire/pkg/common/api" "github.com/spiffe/spire/pkg/common/pemutil" - "github.com/spiffe/spire/pkg/server/api" "github.com/spiffe/spire/test/spiretest" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" @@ -201,7 +201,7 @@ func TestUpdate(t *testing.T) { fakeResp: &trustdomainv1.BatchUpdateFederationRelationshipResponse{ Results: []*trustdomainv1.BatchUpdateFederationRelationshipResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: frSPIFFE, }, }, @@ -239,7 +239,7 @@ Endpoint SPIFFE ID : spiffe://other.org/bundle fakeResp: &trustdomainv1.BatchUpdateFederationRelationshipResponse{ Results: []*trustdomainv1.BatchUpdateFederationRelationshipResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: frSPIFFEAndBundle, }, }, @@ -290,7 +290,7 @@ Endpoint SPIFFE ID : spiffe://td-3.org/bundle fakeResp: &trustdomainv1.BatchUpdateFederationRelationshipResponse{ Results: []*trustdomainv1.BatchUpdateFederationRelationshipResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: frWeb, }, }, @@ -368,9 +368,9 @@ Error: failed to update one or more federation relationships }, fakeResp: &trustdomainv1.BatchUpdateFederationRelationshipResponse{ Results: []*trustdomainv1.BatchUpdateFederationRelationshipResponse_Result{ - {FederationRelationship: frWeb, Status: api.OK()}, - {FederationRelationship: frSPIFFE, Status: api.OK()}, - {FederationRelationship: frPemAuthority, Status: api.OK()}, + {FederationRelationship: frWeb, Status: commonapi.OK()}, + {FederationRelationship: frSPIFFE, Status: commonapi.OK()}, + {FederationRelationship: frPemAuthority, Status: commonapi.OK()}, }, }, expOutPretty: ` diff --git a/cmd/spire-server/cli/logger/get.go b/cmd/spire-server/cli/logger/get.go index b6753a3df6..17995a67b0 100644 --- a/cmd/spire-server/cli/logger/get.go +++ b/cmd/spire-server/cli/logger/get.go @@ -8,6 +8,7 @@ import ( "github.com/mitchellh/cli" api "github.com/spiffe/spire-api-sdk/proto/spire/api/server/logger/v1" "github.com/spiffe/spire/cmd/spire-server/util" + commonlogger "github.com/spiffe/spire/pkg/common/api/logger" commoncli "github.com/spiffe/spire/pkg/common/cli" "github.com/spiffe/spire/pkg/common/cliprinter" ) @@ -55,5 +56,5 @@ func (c *getCommand) Run(ctx context.Context, _ *commoncli.Env, serverClient uti // Formatting for the logger under pretty printing of output. func (c *getCommand) prettyPrintLogger(env *commoncli.Env, results ...any) error { - return PrettyPrintLogger(env, results...) + return commonlogger.PrettyPrintLogger(env, results...) } diff --git a/cmd/spire-server/cli/logger/mocks_test.go b/cmd/spire-server/cli/logger/mocks_test.go index e4d45f4528..16bf663c81 100644 --- a/cmd/spire-server/cli/logger/mocks_test.go +++ b/cmd/spire-server/cli/logger/mocks_test.go @@ -3,7 +3,6 @@ package logger_test import ( "bytes" "context" - "io" "testing" "github.com/spiffe/spire/test/clitest" @@ -89,21 +88,3 @@ func (s *mockLoggerService) ResetLogLevel(context.Context, *loggerv1.ResetLogLev s.receivedSetValue = nil return s.returnLogger, s.returnErr } - -var _ io.Writer = &errorWriter{} - -type errorWriter struct { - ReturnError error - Buffer bytes.Buffer -} - -func (e *errorWriter) Write(p []byte) (n int, err error) { - if e.ReturnError != nil { - return 0, e.ReturnError - } - return e.Buffer.Write(p) -} - -func (e *errorWriter) String() string { - return e.Buffer.String() -} diff --git a/cmd/spire-server/cli/logger/reset.go b/cmd/spire-server/cli/logger/reset.go index e319579bb0..d892519e9d 100644 --- a/cmd/spire-server/cli/logger/reset.go +++ b/cmd/spire-server/cli/logger/reset.go @@ -8,6 +8,7 @@ import ( "github.com/mitchellh/cli" api "github.com/spiffe/spire-api-sdk/proto/spire/api/server/logger/v1" "github.com/spiffe/spire/cmd/spire-server/util" + commonlogger "github.com/spiffe/spire/pkg/common/api/logger" commoncli "github.com/spiffe/spire/pkg/common/cli" "github.com/spiffe/spire/pkg/common/cliprinter" ) @@ -53,5 +54,5 @@ func (c *resetCommand) Run(ctx context.Context, _ *commoncli.Env, serverClient u } func (c *resetCommand) prettyPrintLogger(env *commoncli.Env, results ...any) error { - return PrettyPrintLogger(env, results...) + return commonlogger.PrettyPrintLogger(env, results...) } diff --git a/cmd/spire-server/cli/logger/set.go b/cmd/spire-server/cli/logger/set.go index c62a9ce2a8..2ac107f046 100644 --- a/cmd/spire-server/cli/logger/set.go +++ b/cmd/spire-server/cli/logger/set.go @@ -11,9 +11,9 @@ import ( "github.com/sirupsen/logrus" api "github.com/spiffe/spire-api-sdk/proto/spire/api/server/logger/v1" "github.com/spiffe/spire/cmd/spire-server/util" + commonlogger "github.com/spiffe/spire/pkg/common/api/logger" commoncli "github.com/spiffe/spire/pkg/common/cli" "github.com/spiffe/spire/pkg/common/cliprinter" - serverlogger "github.com/spiffe/spire/pkg/server/api/logger/v1" ) type setCommand struct { @@ -61,7 +61,7 @@ func (c *setCommand) Run(ctx context.Context, _ *commoncli.Env, serverClient uti return fmt.Errorf("the value %q is not a valid setting", c.newLevel) } - apiLevel, found := serverlogger.APILevel[logrusLevel] + apiLevel, found := commonlogger.APILevel[logrusLevel] if !found { return fmt.Errorf("the logrus level %q could not be transformed into an api log level", level) } @@ -76,5 +76,5 @@ func (c *setCommand) Run(ctx context.Context, _ *commoncli.Env, serverClient uti } func (c *setCommand) prettyPrintLogger(env *commoncli.Env, results ...any) error { - return PrettyPrintLogger(env, results...) + return commonlogger.PrettyPrintLogger(env, results...) } diff --git a/cmd/spire-server/cli/logger/set_test.go b/cmd/spire-server/cli/logger/set_test.go index 2fc4e93ed3..2ac3a4028e 100644 --- a/cmd/spire-server/cli/logger/set_test.go +++ b/cmd/spire-server/cli/logger/set_test.go @@ -141,6 +141,9 @@ Launch Level : info test := setupCliTest(t, tt.service, logger.NewSetCommandWithEnv) returnCode := test.client.Run(append(test.args, tt.args...)) require.Equal(t, tt.expectReturnCode, returnCode) + if tt.expectReturnCode == 0 { + require.Equal(t, tt.expectedSetValue, *test.server.receivedSetValue) + } require.Equal(t, tt.expectStderr, test.stderr.String()) require.Equal(t, tt.expectStdout, test.stdout.String()) }) diff --git a/cmd/spire-server/cli/run/run.go b/cmd/spire-server/cli/run/run.go index 7a0a1533e8..d808091c37 100644 --- a/cmd/spire-server/cli/run/run.go +++ b/cmd/spire-server/cli/run/run.go @@ -19,12 +19,12 @@ import ( "syscall" "time" + "dario.cat/mergo" "github.com/google/go-cmp/cmp" "github.com/hashicorp/hcl" "github.com/hashicorp/hcl/hcl/ast" "github.com/hashicorp/hcl/hcl/printer" "github.com/hashicorp/hcl/hcl/token" - "github.com/imdario/mergo" "github.com/mitchellh/cli" "github.com/sirupsen/logrus" "github.com/spiffe/go-spiffe/v2/spiffeid" @@ -87,6 +87,7 @@ type serverConfig struct { LogFormat string `hcl:"log_format"` LogSourceLocation bool `hcl:"log_source_location"` PruneAttestedNodesExpiredFor string `hcl:"prune_attested_nodes_expired_for"` + PruneAttestedNodesBatchSize int `hcl:"prune_attested_nodes_batch_size"` PruneNonReattestableNodes bool `hcl:"prune_tofu_nodes"` ProxyProtocolTrustedCIDRs []string `hcl:"proxy_protocol_trusted_cidrs"` RateLimit rateLimitConfig `hcl:"ratelimit"` @@ -710,6 +711,8 @@ func NewServerConfig(c *Config, logOptions []log.Option, allowUnknownConfig bool if c.Server.PruneNonReattestableNodes { sc.PruneNonReattestableNodes = c.Server.PruneNonReattestableNodes } + + sc.PruneAttestedNodesBatchSize = c.Server.PruneAttestedNodesBatchSize } if c.Server.DisableJWTSVIDs { diff --git a/cmd/spire-server/cli/run/run_test.go b/cmd/spire-server/cli/run/run_test.go index b5a876cf8f..fe5e043c56 100644 --- a/cmd/spire-server/cli/run/run_test.go +++ b/cmd/spire-server/cli/run/run_test.go @@ -534,6 +534,25 @@ func TestNewServerConfig(t *testing.T) { } cases := []newServerConfigCase{ + { + msg: "prune_attested_nodes_batch_size should be correctly parsed", + input: func(c *Config) { + c.Server.PruneAttestedNodesExpiredFor = "1h" + c.Server.PruneAttestedNodesBatchSize = 5000 + }, + test: func(t *testing.T, c *server.Config) { + require.Equal(t, 5000, c.PruneAttestedNodesBatchSize) + }, + }, + { + msg: "prune_attested_nodes_batch_size is left unset for the datastore to default", + input: func(c *Config) { + c.Server.PruneAttestedNodesExpiredFor = "1h" + }, + test: func(t *testing.T, c *server.Config) { + require.Equal(t, 0, c.PruneAttestedNodesBatchSize) + }, + }, { msg: "bind_address and bind_port should be correctly parsed", input: func(c *Config) { diff --git a/conf/agent/agent_full.conf b/conf/agent/agent_full.conf index 75090a78c3..2dd5be64fc 100644 --- a/conf/agent/agent_full.conf +++ b/conf/agent/agent_full.conf @@ -50,6 +50,11 @@ agent { # log_level: Sets the logging level . Default: INFO log_level = "DEBUG" + # log_selectors: Workload selector prefixes allowed in diagnostic logs. + # Selector values can contain sensitive information. Only configure prefixes + # whose values are acceptable to write to logs. + # log_selectors = ["k8s:ns", "k8s:sa", "unix:user"] + # server_address: DNS name or IP address of the SPIRE server. server_address = "127.0.0.1" @@ -121,6 +126,73 @@ agent { # # use_sync_authorized_entries: Use SyncAuthorizedEntries API for periodic synchronization # # of authorized entries. # use_sync_authorized_entries = true + + # # ratelimit: Optional per-caller rate limiting for Workload API and SDS methods. + # # Each value specifies the maximum number of calls (or stream opens) per second + # # per attested selector set. A value of 0 (the default) disables rate limiting + # # for that method. + # # ratelimit { + # # # fetch_x509_svid: Max stream opens/sec per selector set for FetchX509SVID. Default: 0 (disabled). + # # # fetch_x509_svid = 0 + + # # # fetch_jwt_svid: Max calls/sec per selector set for FetchJWTSVID (unary). Default: 0 (disabled). + # # # fetch_jwt_svid = 0 + + # # # fetch_x509_bundles: Max stream opens/sec per selector set for FetchX509Bundles. Default: 0 (disabled). + # # # fetch_x509_bundles = 0 + + # # # fetch_jwt_bundles: Max stream opens/sec per selector set for FetchJWTBundles. Default: 0 (disabled). + # # # fetch_jwt_bundles = 0 + + # # # stream_secrets: Max stream opens/sec per selector set for SDS StreamSecrets. Default: 0 (disabled). + # # # stream_secrets = 0 + + # # # fetch_secrets: Max calls/sec per selector set for SDS FetchSecrets (unary). Default: 0 (disabled). + # # # fetch_secrets = 0 + # # } + + # # broker: SPIFFE Broker API endpoint configuration. The broker endpoint + # # is opt-in; omitting the block disables it. At least one of socket_path + # # or bind_address MUST be set; both MAY be set to expose the endpoint + # # over both transports simultaneously. Kept under `experimental` while + # # the spec stabilizes — breaking changes may land before this moves to + # # the top-level config. + # # broker { + # # # socket_path: Bind the broker endpoint to a Unix domain socket + # # # at the given path. POSIX-only. Mutually compatible with + # # # bind_address (both can be set). + # # # socket_path = "/tmp/spire-agent/broker/api.sock" + # # + # # # bind_address: Bind the broker endpoint to a TCP address + # # # ("host:port"). Works on POSIX and Windows. The broker + # # # endpoint authenticates callers via mutual TLS using X.509-SVIDs + # # # so it is safe to expose over the network. + # # # bind_address = "0.0.0.0:8443" + # # + # # # brokers: brokers authorized to talk to this endpoint. At + # # # least one broker MUST be configured for the endpoint to + # # # start. Each entry's id is any valid SPIFFE ID; + # # # cross-trust-domain broker identities are allowed. + # # # allowed_reference_types restricts which WorkloadReference + # # # types the broker may use. Each entry requires type_url, + # # # the verbatim protobuf type URL the workload attestor plugin + # # # matches against. allow_over_tcp defaults to false; set it + # # # to true only for reference types safe over the network. + # # # At least one entry is required. Use a single object with + # # # type_url = "*" to allow any reference type the agent's + # # # attestor stack understands. + # # # brokers = [ + # # # { + # # # id = "spiffe://example.org/broker" + # # # allowed_reference_types = [ + # # # { + # # # type_url = "type.googleapis.com/spiffe.broker.KubernetesObjectReference" + # # # allow_over_tcp = true + # # # }, + # # # ] + # # # }, + # # # ] + # # } # } } @@ -437,6 +509,71 @@ plugins { # Defaults to false. (Linux only) # verbose_container_locator_logs = false + # experimental: Experimental plugin options, subject to change or + # removal. Holds the Kubernetes API server and SPIFFE Broker API + # configuration. + # + # experimental.api_server.cache.enabled: If true, enables a + # controller-runtime Kubernetes API server cache for + # object-reference lookups. Defaults to false. + # + # experimental.broker: Broker API-specific configuration used by AttestReference. + # Broker API uses AttestReference, so this block is required for + # all Broker API references handled by this plugin, including + # WorkloadPIDReference and KubernetesObjectReference. Each broker + # entry identifies a broker SPIFFE ID. IDs must be valid, unique, + # and non-empty. access_policy controls whether the plugin runs + # SubjectAccessReview checks for resolved objects. It must be set + # to "enforced" or "permissive". When set to "enforced", + # the broker SPIFFE ID is used as the + # SubjectAccessReview username; no groups are set. + # pod_reference_scope controls pod KubernetesObjectReference + # resolution for the broker. Valid values are "agent_node" + # (default) and "cluster". agent_node only accepts pods whose + # spec.nodeName matches this agent's node name. Non-pod object + # references always use the Kubernetes API server. + # + # Additional Kubernetes authorization requirements when + # access_policy = "enforced": + # * The SPIRE agent ServiceAccount must be allowed to create + # subjectaccessreviews.authorization.k8s.io resources. + # * The broker SPIFFE ID must be allowed to use SPIRE's custom + # Kubernetes authorization verb "impersonate-via-spire" on + # every Kubernetes resource brokers may reference. Do not + # grant Kubernetes' built-in "impersonate" verb for this + # purpose: that verb can grant Kubernetes-native impersonation + # of users, groups, and ServiceAccounts. The custom verb is + # only a SPIRE broker authorization gate. + # + # Example broker-side Kubernetes RBAC for broker ID + # spiffe://example.org/broker: + # kind: ClusterRole + # rules: + # - apiGroups: [""] + # resources: ["pods"] + # verbs: ["impersonate-via-spire"] + # kind: ClusterRoleBinding + # subjects: + # - kind: User + # name: spiffe://example.org/broker + # + # experimental { + # api_server { + # cache { + # enabled = false + # } + # } + # broker { + # access_policy = "enforced" + # brokers = [ + # { + # id = "spiffe://example.org/broker" + # pod_reference_scope = "cluster" + # } + # ] + # } + # } + # sigstore: sigstore options. Enables image cosign signatures checking. # sigstore { # allowed_identities: Maps OIDC issuer URIs to acceptable SANs in Fulcio certificates for validating signatures. @@ -520,9 +657,29 @@ plugins { # # optional TLS configuration for Prometheus exporter. # tls { +# # use_spire_svid: Use the current SPIRE SVID for the Prometheus +# # endpoint instead of cert_file/key_file. +# # use_spire_svid = false +# +# # authorized_spiffe_ids: Optional list of SPIFFE IDs allowed to +# # connect to the Prometheus endpoint. Cannot be used with +# # client_ca_file. +# # authorized_spiffe_ids = ["spiffe://example.org/monitoring/prometheus"] +# +# # cert_file: Path to the PEM-encoded certificate to serve from +# # the Prometheus endpoint. Must be set with key_file unless +# # use_spire_svid is enabled. # cert_file = "/path/to/cert.pem" +# +# # key_file: Path to the PEM-encoded private key to serve from the +# # Prometheus endpoint. Must be set with cert_file unless +# # use_spire_svid is enabled. # key_file = "/path/to/key.pem" -# client_ca_file = "/path/to/ca.pem" # optional CA file for mTLS +# +# # client_ca_file: Optional PEM-encoded CA bundle used to verify +# # Prometheus clients for mTLS. Cannot be used with +# # authorized_spiffe_ids. +# client_ca_file = "/path/to/ca.pem" # } # } diff --git a/conf/server/server_full.conf b/conf/server/server_full.conf index 0e9a52b4b7..15d262e486 100644 --- a/conf/server/server_full.conf +++ b/conf/server/server_full.conf @@ -156,6 +156,21 @@ server { # the load balancer IP. Leave unset or empty to disable PROXY protocol. # proxy_protocol_trusted_cidrs = ["10.0.0.0/8", "172.16.0.0/12"] + # prune_attested_nodes_expired_for: Enables periodic pruning of attested + # node records whose SVIDs expired further in the past than this duration. + # Non-reattestable nodes are not pruned unless prune_tofu_nodes is true, and + # banned nodes are never pruned. Leave unset to disable pruning. + # prune_attested_nodes_expired_for = "720h" + + # prune_tofu_nodes: Includes expired TOFU (non-reattestable) nodes when + # pruning. Has no effect on banned nodes. Default: false. + # prune_tofu_nodes = false + + # prune_attested_nodes_batch_size: Maximum number of expired attested nodes + # pruned per cycle. Only applies when prune_attested_nodes_expired_for is + # set. Default: 1000. + # prune_attested_nodes_batch_size = 1000 + # ratelimit: Holds rate limiting configurations. # ratelimit = { # # Controls whether node attestation is rate limited to one @@ -1010,9 +1025,29 @@ plugins { # # optional TLS configuration for Prometheus exporter. # tls { +# # use_spire_svid: Use the current SPIRE SVID for the Prometheus +# # endpoint instead of cert_file/key_file. +# # use_spire_svid = false +# +# # authorized_spiffe_ids: Optional list of SPIFFE IDs allowed to +# # connect to the Prometheus endpoint. Cannot be used with +# # client_ca_file. +# # authorized_spiffe_ids = ["spiffe://example.org/monitoring/prometheus"] +# +# # cert_file: Path to the PEM-encoded certificate to serve from +# # the Prometheus endpoint. Must be set with key_file unless +# # use_spire_svid is enabled. # cert_file = "/path/to/cert.pem" +# +# # key_file: Path to the PEM-encoded private key to serve from the +# # Prometheus endpoint. Must be set with cert_file unless +# # use_spire_svid is enabled. # key_file = "/path/to/key.pem" -# client_ca_file = "/path/to/ca.pem" # optional CA file for mTLS +# +# # client_ca_file: Optional PEM-encoded CA bundle used to verify +# # Prometheus clients for mTLS. Cannot be used with +# # authorized_spiffe_ids. +# client_ca_file = "/path/to/ca.pem" # } # } diff --git a/doc/plugin_agent_workloadattestor_k8s.md b/doc/plugin_agent_workloadattestor_k8s.md index c42ff17614..d2bb3f6d23 100644 --- a/doc/plugin_agent_workloadattestor_k8s.md +++ b/doc/plugin_agent_workloadattestor_k8s.md @@ -46,22 +46,30 @@ server name validation against the kubelet certificate. **Note** To run on Windows containers, Kubernetes v1.24+ and containerd v1.6+ are required, since [hostprocess](https://kubernetes.io/docs/tasks/configure-pod-container/create-hostprocess-pod/) container is required on the agent container. -| Configuration | Description | -|----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `disable_container_selectors` | If true, container selectors are not produced. This can be used to produce pod selectors when the workload pod is known but the workload container is not ready at the time of attestation. | -| `kubelet_read_only_port` | The kubelet read-only port. This is mutually exclusive with `kubelet_secure_port`. | -| `kubelet_secure_port` | The kubelet secure port. It defaults to `10250` unless `kubelet_read_only_port` is set. | -| `kubelet_ca_path` | The path on disk to a file containing CA certificates used to verify the kubelet certificate. Required unless `skip_kubelet_verification` is set. Defaults to the cluster CA bundle `/run/secrets/kubernetes.io/serviceaccount/ca.crt`. | -| `skip_kubelet_verification` | If true, kubelet certificate verification is skipped | -| `token_path` | The path on disk to the bearer token used for kubelet authentication. Defaults to the service account token `/run/secrets/kubernetes.io/serviceaccount/token` | -| `certificate_path` | The path on disk to client certificate used for kubelet authentication | -| `private_key_path` | The path on disk to client key used for kubelet authentication | -| `use_anonymous_authentication` | If true, use anonymous authentication for kubelet communication | -| `node_name_env` | The environment variable used to obtain the node name. Defaults to `MY_NODE_NAME`. | -| `node_name` | The name of the node. Overrides the value obtained by the environment variable specified by `node_name_env`. | -| `sigstore` | Sigstore options. Options described below. See [Sigstore options](#sigstore-options). When set, enables verification of container image signatures and attestations. | -| `use_new_container_locator` | If true, enables the new container locator algorithm that has support for cgroups v2. Defaults to true. | -| `verbose_container_locator_logs` | If true, enables verbose logging of mountinfo and cgroup information used to locate containers. Defaults to false. | +| Configuration | Description | +|-----------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `disable_container_selectors` | If true, container selectors are not produced. This can be used to produce pod selectors when the workload pod is known but the workload container is not ready at the time of attestation. | +| `kubelet_read_only_port` | The kubelet read-only port. This is mutually exclusive with `kubelet_secure_port`. | +| `kubelet_secure_port` | The kubelet secure port. It defaults to `10250` unless `kubelet_read_only_port` is set. | +| `kubelet_ca_path` | The path on disk to a file containing CA certificates used to verify the kubelet certificate. Required unless `skip_kubelet_verification` is set. Defaults to the cluster CA bundle `/run/secrets/kubernetes.io/serviceaccount/ca.crt`. | +| `skip_kubelet_verification` | If true, kubelet certificate verification is skipped | +| `token_path` | The path on disk to the bearer token used for kubelet authentication. Defaults to the service account token `/run/secrets/kubernetes.io/serviceaccount/token` | +| `certificate_path` | The path on disk to client certificate used for kubelet authentication | +| `private_key_path` | The path on disk to client key used for kubelet authentication | +| `use_anonymous_authentication` | If true, use anonymous authentication for kubelet communication | +| `node_name_env` | The environment variable used to obtain the node name. Defaults to `MY_NODE_NAME`. | +| `node_name` | The name of the node. Overrides the value obtained by the environment variable specified by `node_name_env`. | +| `experimental` | The experimental options that are subject to change or removal (see below). | +| `sigstore` | Sigstore options. Options described below. See [Sigstore options](#sigstore-options). When set, enables verification of container image signatures and attestations. | +| `use_new_container_locator` | If true, enables the new container locator algorithm that has support for cgroups v2. Defaults to true. | +| `verbose_container_locator_logs` | If true, enables verbose logging of mountinfo and cgroup information used to locate containers. Defaults to false. | + +These are the current experimental configurations. + +| experimental | Description | Default | +|:---------------------------|-----------------------------------------------------------------------------------------------------------------------------------|---------| +| `api_server.cache.enabled` | If true, enables a controller-runtime Kubernetes API server cache for object-reference lookups. | false | +| `broker` | Broker API options for `AttestReference`. Required when this plugin handles Broker API references. See [Broker API](#broker-api). | | ## Sigstore feature @@ -86,7 +94,7 @@ CA roots, ensuring that only trusted certificates are used during validation. Ad certificate validation can be specified via the `SIGSTORE_ROOT_FILE` environment variable. For more details on Cosign configurations, refer to the [documentation](https://github.com/sigstore/cosign/blob/main/README.md). -### K8s selectors +## K8s selectors | Selector | Value | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -125,7 +133,199 @@ If `ignore_tlog` is set to `true`, the selectors based on the Rekor bundle (`-lo > the pod, whereas `pod-image` and `pod-init-image` will match against ANY container or init container in the Pod, > respectively. -### Image selector limitations +## Broker API + +When SPIRE Agent's [SPIFFE Broker API](spire_agent.md#spiffe-broker-api) is +enabled, the k8s workload attestor handles Broker API `AttestReference` +requests for `WorkloadPIDReference` and `KubernetesObjectReference`. +`AttestReference` requires an `experimental.broker` block in the plugin configuration. Each +`experimental.broker.brokers` entry identifies one broker SPIFFE ID that may use this +plugin. Broker IDs must be valid, unique, and non-empty. The required +block-level `access_policy` setting controls whether the plugin creates +Kubernetes `SubjectAccessReview` requests for resolved objects. Use +`access_policy = "enforced"` to authorize every resolved object with +Kubernetes before selectors are returned. Use `access_policy = "permissive"` +to skip that authorization check. Each broker may set `pod_reference_scope` to +`agent_node` (default) or `cluster`; this only affects pod +`KubernetesObjectReference` resolution. + +Example: + +```hcl +WorkloadAttestor "k8s" { + plugin_data { + experimental { + broker { + access_policy = "enforced" + brokers = [ + { + id = "spiffe://example.org/broker" + pod_reference_scope = "cluster" + } + ] + } + } + } +} +``` + +`WorkloadPIDReference` follows the PID-based k8s attestation path to +resolve the workload pod and selectors. When that Broker API reference +resolves to a pod and `experimental.broker.access_policy = "enforced"`, the plugin creates a +`SubjectAccessReview` asking whether the broker SPIFFE ID may use SPIRE's +custom Kubernetes authorization verb `impersonate-via-spire` on the resolved +pod. The review uses the broker SPIFFE ID as the SAR username and does not +set groups. PID-based attestation via the workload attestor's `Attest` RPC +does not use the broker configuration or run this review. + +For `KubernetesObjectReference`, the reference identifies the target object by +its resource (`.`, with `core` as the group string for core +resources) and either its namespaced name (`namespace` + `name`), its `uid`, +or both. Pod references try the local kubelet pod list first. With the default +`pod_reference_scope = "agent_node"`, pod references are limited to information +returned by the local kubelet and do not fall back to the Kubernetes API +server. With `pod_reference_scope = "cluster"`, pod references may fall back to +the Kubernetes API server and resolve pods on any node. Non-pod object +references are resolved through the Kubernetes API server. When +`experimental.broker.access_policy = "enforced"`, the plugin then creates the same +`SubjectAccessReview` for the referenced object. The review uses the broker +SPIFFE ID as the SAR username, no groups, the reference's resource group and +plural, and the resolved namespace and name. If the authorizer denies the +review, attestation fails with `PermissionDenied`. Kubernetes API server +lookups require the agent ServiceAccount to have permission for the referenced +resource. + +**Pods (`pods/core`).** A `KubernetesObjectReference` to a pod is attested +through the same pod-resolution path as the PID-based reference and emits +the **same** pod-shaped selectors documented in the table above +(`k8s:ns`, `k8s:sa`, `k8s:pod-name`, `k8s:container-name`, `k8s:pod-uid`, +`k8s:pod-label`, `k8s:pod-image`, `k8s:pod-owner`, ...). By default, broker +pod references are limited to pods returned by the local kubelet +(`agent_node`). Set `pod_reference_scope = "cluster"` for a broker that must +reference pods on other nodes through the Kubernetes API server. A registration +entry written for the PID-based flow continues to match either reference type. + +**Other resources (any `.` for which the agent has permission).** +The agent fetches the object's `metadata` via the Kubernetes API server +(using a `PartialObjectMetadata` request) and emits a uniform vocabulary +that is independent of the resource's kind: + +| Selector | Value | +|-------------------------|-------------------------------------------------------------------------------------------------------------| +| k8s:uid | The object's UID. | +| k8s:resource | `.` for the resource (e.g. `deployments.apps`, `pods.core`). | +| k8s:plural | The resource plural (e.g. `deployments`). | +| k8s:group | The API group (e.g. `apps`); `core` for core resources. | +| k8s:version | The discovered version (e.g. `v1`, `v1beta1`). | +| k8s:apiVersion | The Kubernetes wire form: `v1` for core or `/` otherwise. | +| k8s:kind | The object kind (e.g. `Deployment`). | +| k8s:name | The object name. | +| k8s:namespace | The object namespace; omitted for cluster-scoped objects. | +| k8s:key | `/` for namespaced objects, or just `` for cluster-scoped ones. | +| k8s:label | A label on the object, formatted `:` (one selector per label entry). | +| k8s:owner-key | `//` for each entry in `metadata.ownerReferences`. | +| k8s:owner-uid | `//` for each entry in `metadata.ownerReferences`. | +| k8s:controller-key | Same as `k8s:owner-key`, but only emitted for owner references with `Controller: true`. | +| k8s:controller-uid | Same as `k8s:owner-uid`, but only emitted for owner references with `Controller: true`. | + +Annotations are intentionally **not** exposed as selectors. Kubernetes labels +are validated and indexed by the API server and are the appropriate identity +anchor; annotations are an unconstrained metadata grab bag (often multi-line +JSON) that does not fit equality-matched selectors. + +The agent's ServiceAccount needs `get` (and `list` when references identify +objects by `uid` alone) permission on every resource it is expected to +resolve via this path. When `experimental.broker.access_policy = "enforced"`, it also needs +`create` on `subjectaccessreviews.authorization.k8s.io` to perform broker +authorization checks. For example: + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: spire-agent-k8s-attestor +rules: + # Object-reference resolution. Add every resource brokers may reference. + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] + - apiGroups: ["kustomize.toolkit.fluxcd.io"] + resources: ["kustomizations"] + verbs: ["get", "list"] + + # Required only when experimental.broker.access_policy = "enforced", so the k8s workload + # attestor can create SubjectAccessReview objects while handling Broker API + # reference requests. + - apiGroups: ["authorization.k8s.io"] + resources: ["subjectaccessreviews"] + verbs: ["create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: spire-agent-k8s-attestor +subjects: + - kind: ServiceAccount + name: spire-agent + namespace: spire +roleRef: + kind: ClusterRole + name: spire-agent-k8s-attestor + apiGroup: rbac.authorization.k8s.io +``` + +When `experimental.broker.access_policy = "enforced"`, the Kubernetes authorizer must also allow +the broker SPIFFE ID to use SPIRE's custom **`impersonate-via-spire`** verb on +the resources brokers may reference. +The `SubjectAccessReview` checks the broker SPIFFE ID as a Kubernetes username +and does not set groups. The broker pod's Kubernetes ServiceAccount is not +used as the reviewed subject. + +SPIRE intentionally does **not** use Kubernetes' built-in `impersonate` verb +for this check. The built-in verb has broader meaning in Kubernetes RBAC: it +can authorize native impersonation of users, groups, ServiceAccounts, and +other impersonation targets. Granting that built-in verb to broker-related +Kubernetes identities would create powerful RBAC subjects with permissions +outside SPIRE's broker authorization decision. The `impersonate-via-spire` +verb is a SPIRE-specific authorization gate checked only by this +`SubjectAccessReview`; granting it lets Kubernetes answer SPIRE's question +without granting Kubernetes-native impersonation power. + +For example, with broker ID `spiffe://example.org/broker`: + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: spire-broker-impersonation +rules: + # This is the broker authorization decision enforced by SubjectAccessReview. + # Use SPIRE's custom verb, not Kubernetes' built-in `impersonate` verb. + - apiGroups: [""] + resources: ["pods"] + verbs: ["impersonate-via-spire"] + - apiGroups: ["kustomize.toolkit.fluxcd.io"] + resources: ["kustomizations"] + verbs: ["impersonate-via-spire"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: spire-broker-impersonation +subjects: + - kind: User + name: spiffe://example.org/broker +roleRef: + kind: ClusterRole + name: spire-broker-impersonation + apiGroup: rbac.authorization.k8s.io +``` + +A `Role` and `RoleBinding` can be used instead when brokers should only be +allowed to authorize SPIRE broker references for namespaced resources in a +specific namespace. + +## Image selector limitations The `container-image`, `pod-image`, and `pod-init-image` selectors are derived from two fields in the Kubernetes [ContainerStatus](https://pkg.go.dev/k8s.io/api/core/v1#ContainerStatus): `Image` (typically the tag-based name, e.g. `myimage:v1.2.3`) and `ImageID` (typically the digest-based identifier, e.g. `myimage@sha256:abc...`). Both values are emitted as selectors for each container to support matching by either form. @@ -201,11 +401,27 @@ WorkloadAttestor "k8s" { } ``` -### Platform support +To enable the Kubernetes API server cache used by object-reference lookups: + +```hcl +WorkloadAttestor "k8s" { + plugin_data { + experimental { + api_server { + cache { + enabled = true + } + } + } + } +} +``` + +## Platform support This plugin is only supported on Unix systems. -### Known issues +## Known issues * This plugin may fail to correctly attest workloads in pods that use lifecycle hooks to alter pod start behavior. This includes Istio workloads when the `holdApplicationUntilProxyStarts` configurable is set to true. Please see [#3092](https://github.com/spiffe/spire/issues/3092) for more information. The `disable_container_selectors` configurable can be used to successfully attest workloads in this situation, albeit with reduced selector granularity (i.e. pod selectors only). diff --git a/doc/plugin_agent_workloadattestor_windows.md b/doc/plugin_agent_workloadattestor_windows.md index 5cd8c85228..2d60445283 100644 --- a/doc/plugin_agent_workloadattestor_windows.md +++ b/doc/plugin_agent_workloadattestor_windows.md @@ -3,10 +3,11 @@ The `windows` plugin generates Windows-based selectors for workloads calling the agent. It does so by opening an access token associated with the workload process. The system is then interrogated to retrieve user and group account information from that access token. -| Configuration | Description | Default | -|--------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------|---------| -| `discover_workload_path` | If true, the workload path will be discovered by the plugin and used to provide additional selectors | false | -| `workload_size_limit` | The limit of workload binary sizes when calculating certain selectors (e.g. sha256). If zero, no limit is enforced. If negative, never calculate the hash. | 0 | +| Configuration | Description | Default | +|--------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------| +| `discover_workload_path` | If true, the workload path will be discovered by the plugin and used to provide additional selectors | false | +| `workload_size_limit` | The limit of workload binary sizes when calculating certain selectors (e.g. sha256). If zero, no limit is enforced. If negative, never calculate the hash. | 0 | +| `disable_group_name_selectors` | If true, skips resolving group SIDs to human-readable names, avoiding potentially expensive account name resolution (e.g. against a Domain Controller). Group SID selectors (`group_sid`) are always collected regardless. Only `group_name` selectors are affected. | false | ## Workload Selectors @@ -41,10 +42,14 @@ Defenses against this are: ### Notes +- The workload attestor opens the access token of the calling process using [OpenProcessToken](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-openprocesstoken). When the workload runs under a more privileged account than the SPIRE agent, this requires the agent process to have the [SE_DEBUG_NAME](https://learn.microsoft.com/en-us/windows/win32/secauthz/privilege-constants) privilege. The SPIRE agent attempts to enable this privilege at startup and logs a warning if it cannot. Ensure the account running the SPIRE agent has been granted this privilege if attestation of higher-privileged workloads is needed. + - An enabled group in a token is a group that has the [SE_GROUP_ENABLED](https://docs.microsoft.com/en-us/windows/win32/secauthz/sid-attributes-in-an-access-token) attribute. - User and group account names are expressed using the [down-level logon name format](https://docs.microsoft.com/en-us/windows/win32/secauthn/user-name-formats#down-level-logon-name). +- Enabling `disable_group_name_selectors` will cause existing workload registration entries that use `group_name` selectors to stop matching. Operators should audit those entries and switch them to `group_sid` selectors before enabling this flag. + ## Configuration This plugin does not require any configuration setting. It can be added in the following way in the agent configuration file: diff --git a/doc/plugin_server_datastore_sql.md b/doc/plugin_server_datastore_sql.md index e3240b6742..9694bcf098 100644 --- a/doc/plugin_server_datastore_sql.md +++ b/doc/plugin_server_datastore_sql.md @@ -16,7 +16,7 @@ The `sql` plugin implements SQL based data storage for the SPIRE server using SQ | disable_migration | True to disable auto-migration functionality. Use of this flag allows finer control over when datastore migrations occur and coordination of the migration of a datastore shared with a SPIRE Server cluster. Only available for databases from SPIRE Code version 0.9.0 or later. | For more information on the `max_open_conns`, `max_idle_conns`, and `conn_max_lifetime`, refer to the -documentation for the Go [`database/sql`](https://golang.org/pkg/database/sql/#DB) package. +documentation for the Go [`database/sql`](https://pkg.go.dev/database/sql#DB) package. > **Note:** The SQL plugin uses an internal default setting of 30 seconds for the maximum idle time per connection (ConnMaxIdleTime). This setting is not configurable through the plugin configuration. @@ -24,16 +24,18 @@ documentation for the Go [`database/sql`](https://golang.org/pkg/database/sql/#D ### `database_type = "sqlite3"` +We use the [mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) driver. It accepts a [SQLite database filename or URI](https://sqlite.org/c3ref/open.html) as a connection string, along with [driver-specific extensions](https://pkg.go.dev/github.com/mattn/go-sqlite3#readme-connection-string) for setting pragmas and similar options. + Save database in file: ```hcl -connection_string="DATABASE_FILE.db" +connection_string = "DATABASE_FILE.db" ``` Save database in memory: ```hcl -connection_string="file:memdb?mode=memory&cache=shared" +connection_string = "file:memdb?mode=memory&cache=shared" ``` If you are compiling SPIRE from source, please see [SQLite and CGO](#sqlite-and-cgo) for additional information. @@ -51,12 +53,12 @@ If you are compiling SPIRE from source, please see [SQLite and CGO](#sqlite-and- ### `database_type = "postgres"` -The `connection_string` for the PostgreSQL database connection consists of the number of configuration options separated by spaces. +The `connection_string` for the PostgreSQL database connection consists of a number of configuration options separated by spaces. For example: ```hcl -connection_string="dbname=postgres user=postgres password=password host=localhost sslmode=disable" +connection_string = "dbname=postgres user=postgres password=password host=localhost sslmode=disable" ``` Consult the [lib/pq driver documentation](https://pkg.go.dev/github.com/lib/pq#hdr-Connection_String_Parameters) for more `connection_string` options. @@ -91,7 +93,7 @@ a number of versions. Older versions may still work, but they are not tested. * require - Always SSL (skip verification) * verify-ca - Always SSL (verify that the certificate presented by the server was signed by a trusted CA) -* verify-full - Always SSL (verify that the certification presented by +* verify-full - Always SSL (verify that the certificate presented by the server was signed by a trusted CA and the server host name matches the one in the certificate) @@ -114,7 +116,7 @@ When connecting to a Cloud SQL PostgreSQL instance, use a connection string with ### `database_type = "mysql"` -The `connection_string` for the MySQL database connection consists of the number of configuration options (optional parts marked by square brackets): +The `connection_string` for the MySQL database connection consists of a number of configuration options (optional parts marked by square brackets): ```text username[:password]@][protocol[(address)]]/dbname[?param1=value1&...¶mN=valueN] @@ -123,15 +125,15 @@ username[:password]@][protocol[(address)]]/dbname[?param1=value1&...¶mN=valu For example: ```hcl -connection_string="username:password@tcp(localhost:3306)/dbname?parseTime=true" +connection_string = "username:password@tcp(localhost:3306)/dbname?parseTime=true" ``` Consult the [MySQL driver repository](https://github.com/go-sql-driver/mysql#usage) for more `connection_string` options. #### Supported MySQL versions -We support all currently maintainted versions of MySQL and run [integration tests](../test/integration/suites/datastore-mysql/) against LTS releases. Older versions of -may still work, but are not tested. +We support all currently maintained versions of MySQL and run [integration tests](../test/integration/suites/datastore-mysql/) against LTS releases. Older versions of +MySQL may still work, but are not tested. #### Configuration Options @@ -184,11 +186,11 @@ For PostgreSQL databases on AWS RDS using IAM authentication. The `region` setti This is the complete list of configuration options under the `database_type` setting when `aws_postgres` is set: -| Configuration | Description | Required | Default | -|-------------------|---------------------------------------|------------------------------------------------------------------------|-----------------------------------------------------| -| access_key_id | AWS access key id. | Required only if AWS_ACCESS_KEY_ID environment variable is not set. | Value of AWS_ACCESS_KEY_ID environment variable. | -| secret_access_key | AWS secret access key. | Required only if AWS_SECRET_ACCESSKEY environment variable is not set. | Value of AWS_SECRET_ACCESSKEY environment variable. | -| region | AWS region of the database. | Yes. | | +| Configuration | Description | Required | Default | +|-------------------|---------------------------------------|-------------------------------------------------------------------------|------------------------------------------------------| +| access_key_id | AWS access key id. | Required only if AWS_ACCESS_KEY_ID environment variable is not set. | Value of AWS_ACCESS_KEY_ID environment variable. | +| secret_access_key | AWS secret access key. | Required only if AWS_SECRET_ACCESS_KEY environment variable is not set. | Value of AWS_SECRET_ACCESS_KEY environment variable. | +| region | AWS region of the database. | Yes. | | Settings of the [`postgres`](#database_type--postgres) database type also apply here. @@ -202,7 +204,7 @@ Settings of the [`postgres`](#database_type--postgres) database type also apply } connection_string = "dbname=spire user=test_user host=spire-test.example.us-east-2.rds.amazonaws.com port=5432 sslmode=require" } - } + } ``` #### "aws_mysql" @@ -211,11 +213,11 @@ For MySQL databases on AWS RDS using IAM authentication. The `region` setting is This is the complete list of configuration options under the `database_type` setting when `aws_mysql` is set: -| Configuration | Description | Required | Default | -|-------------------|---------------------------------------|------------------------------------------------------------------------|-----------------------------------------------------| -| access_key_id | AWS access key id. | Required only if AWS_ACCESS_KEY_ID environment variable is not set. | Value of AWS_ACCESS_KEY_ID environment variable. | -| secret_access_key | AWS secret access key. | Required only if AWS_SECRET_ACCESSKEY environment variable is not set. | Value of AWS_SECRET_ACCESSKEY environment variable. | -| region | AWS region of the database. | Yes. | | +| Configuration | Description | Required | Default | +|-------------------|---------------------------------------|-------------------------------------------------------------------------|------------------------------------------------------| +| access_key_id | AWS access key id. | Required only if AWS_ACCESS_KEY_ID environment variable is not set. | Value of AWS_ACCESS_KEY_ID environment variable. | +| secret_access_key | AWS secret access key. | Required only if AWS_SECRET_ACCESS_KEY environment variable is not set. | Value of AWS_SECRET_ACCESS_KEY environment variable. | +| region | AWS region of the database. | Yes. | | Settings of the [`mysql`](#database_type--mysql) database type also apply here. diff --git a/doc/plugin_server_keymanager_aws_kms.md b/doc/plugin_server_keymanager_aws_kms.md index 2d1679c3ae..74abfbf442 100644 --- a/doc/plugin_server_keymanager_aws_kms.md +++ b/doc/plugin_server_keymanager_aws_kms.md @@ -6,16 +6,17 @@ The `aws_kms` key manager plugin leverages the AWS Key Management Service (KMS) The plugin accepts the following configuration options: -| Key | Type | Required | Description | Default | -|----------------------|--------|---------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------| -| access_key_id | string | see [AWS KMS Access](#aws-kms-access) | The Access Key Id used to authenticate to KMS | Value of the AWS_ACCESS_KEY_ID environment variable | -| secret_access_key | string | see [AWS KMS Access](#aws-kms-access) | The Secret Access Key used to authenticate to KMS | Value of the AWS_SECRET_ACCESS_KEY environment variable | -| region | string | yes | The region where the keys will be stored | | -| key_identifier_file | string | Required if key_identifier_value is not set | A file path location where information about generated keys will be persisted | | -| key_identifier_value | string | Required if key_identifier_file is not set | A static identifier for the SPIRE server instance (used instead of `key_identifier_file`) | | -| key_policy_file | string | no | A file path location to a custom key policy in JSON format | "" | - -### Alias and Key Management +| Key | Type | Required | Description | Default | +|----------------------------------|---------|---------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------| +| access_key_id | string | see [AWS KMS Access](#aws-kms-access) | The Access Key Id used to authenticate to KMS | Value of the AWS_ACCESS_KEY_ID environment variable | +| secret_access_key | string | see [AWS KMS Access](#aws-kms-access) | The Secret Access Key used to authenticate to KMS | Value of the AWS_SECRET_ACCESS_KEY environment variable | +| region | string | yes | The region where the keys will be stored | | +| key_identifier_file | string | Required if key_identifier_value is not set | A file path location where information about generated keys will be persisted | | +| key_identifier_value | string | Required if key_identifier_file is not set | A static identifier for the SPIRE server instance (used instead of `key_identifier_file`) | | +| key_policy_file | string | no | A file path location to a custom key policy in JSON format | "" | +| enable_tag_based_key_discovery | boolean | no | Enable tag-based key discovery (recommended). See [Tag-based Key Discovery](#tag-based-key-discovery). | false | + +### Server Instance Identification The plugin needs a way to identify the specific server instance where it's running. For that, either the `key_identifier_file` or `key_identifier_value` @@ -32,9 +33,40 @@ If you need more control over the identifier that's used for the server, the static identifier for the server instance. This setting is appropriate in situations where a key identifier file can't be persisted. -The plugin assigns [aliases](https://docs.aws.amazon.com/kms/latest/developerguide/kms-alias.html) to the Customer Master Keys that it manages. The aliases are used to identify and name keys that are managed by the plugin. +### Tag-based Key Discovery + +> **Recommended.** Enable with `enable_tag_based_key_discovery = true`. + +When tag-based key discovery is enabled, the plugin uses the [AWS Resource Groups Tagging API](https://docs.aws.amazon.com/resourcegroupstagging/latest/APIReference/overview.html) to efficiently find only the KMS keys managed by this plugin instance. Keys are identified using the following SPIRE-specific tags applied at creation time: + +| Tag key | Description | +|---------------------|--------------------------------------------------------------------------------| +| `spire-server-td` | The trust domain of the SPIRE server | +| `spire-server-id` | The server instance identifier | +| `spire-active` | Set to `true` for actively managed keys | +| `spire-key-id` | The SPIRE key identifier | +| `spire-last-update` | Unix timestamp of the last update (set at creation and refreshed periodically) | + +The plugin stamps `spire-last-update` when a key is created (and when migrating an existing key) and periodically refreshes it on all active keys. Any key whose `spire-last-update` timestamp is older than two weeks is considered stale, marked inactive (`spire-active=false`), and its associated KMS key is scheduled for deletion. + +**Migration from alias-based discovery:** When enabling tag-based discovery on a server that previously used alias-based discovery, the plugin automatically detects existing untagged keys during startup and applies SPIRE tags to them. No manual migration steps are required. + +This mode requires the `tag:GetResources` permission (from the Resource Groups Tagging API). See [AWS KMS Access](#aws-kms-access) for the full permission list. + +#### Configuration consistency in HA deployments + +The `enable_tag_based_key_discovery` setting should be configured consistently across all SPIRE servers in the same trust domain. As with alias-based discovery, each server periodically refreshes a liveness signal on the keys it manages, and any server will reclaim keys in its trust domain whose signal has not been refreshed for two weeks (this is how keys belonging to a permanently-removed server are cleaned up). + +The two discovery modes use independent liveness signals: alias-based discovery refreshes the alias `LastUpdatedDate`, while tag-based discovery refreshes the `spire-last-update` tag. A server only refreshes the signal for its currently configured mode. This has an implication for rollbacks: + +- Migrating forward (alias-based to tag-based) across the fleet is safe: keys are tagged automatically on startup and refreshed from then on. +- Rolling back from tag-based to alias-based on part of the fleet while other servers remain tag-based is only safe within the two-week window. A rolled-back server keeps its aliases fresh but stops refreshing `spire-last-update`, so after two weeks the still tag-based servers will treat its in-use keys as abandoned and schedule them for deletion. To roll back safely, change the setting across all servers in the trust domain within that window. + +### Alias-based Key Discovery + +> **Note:** Alias-based key discovery will be deprecated in a future version and removed in a later one. Enable `enable_tag_based_key_discovery` to migrate. -Aliases managed by the plugin have the following form: `alias/SPIRE_SERVER/{TRUST_DOMAIN}/{SERVER_ID}/{KEY_ID}`. The `{SERVER_ID}` is the identifier handled by the `key_identifier_file` or `key_identifier_value` setting. This ID allows multiple servers in the same trust domain (e.g. servers in HA deployments) to manage keys with identical `{KEY_ID}`'s without collision. The `{KEY_ID}` in the alias name is encoded to use a [character set accepted by KMS](https://docs.aws.amazon.com/kms/latest/APIReference/API_CreateAlias.html#API_CreateAlias_RequestSyntax). +By default, the plugin uses alias-based key discovery. The plugin assigns [aliases](https://docs.aws.amazon.com/kms/latest/developerguide/kms-alias.html) to the Customer Master Keys that it manages. Aliases have the following form: `alias/SPIRE_SERVER/{TRUST_DOMAIN}/{SERVER_ID}/{KEY_ID}`. The `{KEY_ID}` in the alias name is encoded to use a [character set accepted by KMS](https://docs.aws.amazon.com/kms/latest/APIReference/API_CreateAlias.html#API_CreateAlias_RequestSyntax). The plugin attempts to detect and prune stale aliases. To facilitate stale alias detection, the plugin actively updates the `LastUpdatedDate` field on all aliases every 6 hours. The plugin periodically scans aliases. Any alias encountered with a `LastUpdatedDate` older than two weeks is removed, along with its associated key. @@ -53,7 +85,7 @@ When using key tagging, you must add the `kms:TagResource` permission to the IAM - Maximum of 50 tags (AWS KMS limit) - Tag keys and values must use valid characters: letters, numbers, spaces, and the following special characters: `+ - = . _ : / @` - Tag keys cannot start with `aws:` (reserved by AWS) -- Tag keys cannot start with `spire-` (reserved for future use) +- Tag keys cannot start with `spire-` (reserved for SPIRE-managed tags) **Tag validation** The plugin validates all user-defined tags during configuration. If any tag violates AWS KMS tagging constraints, the plugin will fail to configure and report a detailed error message indicating the specific validation failure. @@ -71,14 +103,22 @@ The IAM role must have an attached policy with the following permissions: - `kms:CreateKey` - `kms:DescribeKey` - `kms:GetPublicKey` -- `kms:ListKeys` - `kms:ListAliases` - `kms:ScheduleKeyDeletion` - `kms:Sign` -- `kms:TagResource` (required when using key tagging) - `kms:UpdateAlias` - `kms:DeleteAlias` +The following additional permissions are required depending on the configuration: + +| Permission | Required when | +|--------------------|-------------------------------------------------------------------------| +| `kms:ListKeys` | Using alias-based key discovery (current default) | +| `kms:TagResource` | Using tag-based key discovery or `key_tags` | +| `tag:GetResources` | Using tag-based key discovery (`enable_tag_based_key_discovery = true`) | + +`tag:GetResources` belongs to the Resource Groups Tagging API, not to KMS. It is an identity-based permission and must be granted in the IAM identity's policy. It cannot be granted through the KMS key policy (including the default policy generated by the plugin). + ### Key policy The plugin can generate keys using a default key policy, or it can load and use a user defined policy. @@ -134,7 +174,19 @@ is set, the plugin uses the policy defined in the file instead of the default po KeyManager "aws_kms" { plugin_data { region = "us-east-2" - key_metadata_file = "./key_metadata" + key_identifier_file = "./key_metadata" + } +} +``` + +### Configuration with tag-based key discovery (recommended) + +```hcl +KeyManager "aws_kms" { + plugin_data { + region = "us-east-2" + key_identifier_file = "./key_metadata" + enable_tag_based_key_discovery = true } } ``` @@ -145,7 +197,8 @@ KeyManager "aws_kms" { KeyManager "aws_kms" { plugin_data { region = "us-east-2" - key_metadata_file = "./key_metadata" + key_identifier_file = "./key_metadata" + enable_tag_based_key_discovery = true key_tags = { Environment = "production" Team = "security" diff --git a/doc/plugin_server_nodeattestor_azure_imds.md b/doc/plugin_server_nodeattestor_azure_imds.md index 72a53be9f6..5e442d42a5 100644 --- a/doc/plugin_server_nodeattestor_azure_imds.md +++ b/doc/plugin_server_nodeattestor_azure_imds.md @@ -15,10 +15,11 @@ attestation or to resolve selectors. ## Configuration -| Configuration | Required | Description | Default | -| --------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -| `tenants` | Required | A map of tenants, keyed by tenant domain, that are authorized for attestation. Tokens for unspecified tenants are rejected. | | -| `agent_path_template` | Optional | A URL path portion format of Agent's SPIFFE ID. Describe in text/template format. | `"/{{ .PluginName }}/{{ .TenantID }}/{{ .SubscriptionID }}/{{ .VMID }}"` | +| Configuration | Required | Description | Default | +| --------------------------- | -------- |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ------------------------------------------------------------------------ | +| `tenants` | Required | A map of tenants, keyed by tenant domain, that are authorized for attestation. Tokens for unspecified tenants are rejected. | | +| `agent_path_template` | Optional | A URL path portion format of Agent's SPIFFE ID. Describe in text/template format. | `"/{{ .PluginName }}/{{ .TenantID }}/{{ .SubscriptionID }}/{{ .VMID }}"` | +| `allowed_metadata_domains` | Optional | A list of allowed Azure metadata domains for certificate validation. Each domain accepts the base domain (e.g., `metadata.azure.com`) and automatically validates certificates with that domain or any subdomain (e.g., `eastus.metadata.azure.com`, `sub.eastus.metadata.azure.com`). | `["metadata.azure.com"]` | Each tenant in the main configuration supports the following @@ -179,6 +180,43 @@ NodeAttestor "azure_imds" { } ``` +#### Configuration with Custom Metadata Domains + +This configuration shows how to configure for non-commercial Azure environments such as Azure Government: + +```hcl +NodeAttestor "azure_imds" { + plugin_data { + tenants = { + "example.onmicrosoft.com" = { + restrict_to_subscriptions = ["d5b40d61-272e-48da-beb9-05f295c42bd6"] + } + } + allowed_metadata_domains = ["metadata.azure.us"] + } +} +``` + +#### Configuration with Multiple Cloud Environments + +This configuration demonstrates how to allow attestation from multiple Azure environments (e.g., commercial and government clouds): + +```hcl +NodeAttestor "azure_imds" { + plugin_data { + tenants = { + "commercial.onmicrosoft.com" = { + restrict_to_subscriptions = ["d5b40d61-272e-48da-beb9-05f295c42bd6"] + } + "government.onmicrosoft.us" = { + restrict_to_subscriptions = ["a1b2c3d4-5678-9012-3456-789012345678"] + } + } + allowed_metadata_domains = ["metadata.azure.com", "metadata.azure.us"] + } +} +``` + ## Selectors The plugin produces the following selectors. diff --git a/doc/plugin_server_nodeattestor_x509pop.md b/doc/plugin_server_nodeattestor_x509pop.md index 2877a13574..72bb873cac 100644 --- a/doc/plugin_server_nodeattestor_x509pop.md +++ b/doc/plugin_server_nodeattestor_x509pop.md @@ -16,15 +16,16 @@ the identity certificate. The SPIFFE ID has the form: spiffe:///spire/agent/x509pop/ ``` -| Configuration | Description | Default | -| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -| `mode` | If `spiffe`, use the spire servers own trust bundle to use for validation. If `external_pki`, use the specified CA(s). | external_pki | -| `svid_prefix` | The prefix of the SVID to use for matching valid SVIDS and exchanging them for Node SVIDs | /spire-exchange | -| `ca_bundle_path` | The path to the trusted CA bundle on disk. The file must contain one or more PEM blocks forming the set of trusted root CA's for chain-of-trust verification. If the CA certificates are in more than one file, use `ca_bundle_paths` instead. | | -| `ca_bundle_paths` | A list of paths to trusted CA bundles on disk. The files must contain one or more PEM blocks forming the set of trusted root CA's for chain-of-trust verification. | | -| `agent_path_template` | A URL path portion format of Agent's SPIFFE ID. Describe in text/template format. | See [Agent Path Template](#agent-path-template) for details | -| `max_intermediates` | Maximum number of intermediate certificates allowed in the certificate chain. This limit helps prevent resource exhaustion attacks. | 4 | -| `max_rsa_key_size` | Maximum RSA key size in bits allowed in certificates. This limit helps prevent resource exhaustion attacks from excessively large keys. | 8192 | +| Configuration | Description | Default | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `mode` | If `spiffe`, use the spire servers own trust bundle to use for validation. If `external_pki`, use the specified CA(s). | external_pki | +| `svid_prefix` | The prefix of the SVID to use for matching valid SVIDS and exchanging them for Node SVIDs | /spire-exchange | +| `ca_bundle_path` | The path to the trusted CA bundle on disk. The file must contain one or more PEM blocks forming the set of trusted root CA's for chain-of-trust verification. If the CA certificates are in more than one file, use `ca_bundle_paths` instead. | | +| `ca_bundle_paths` | A list of paths to trusted CA bundles on disk. The files must contain one or more PEM blocks forming the set of trusted root CA's for chain-of-trust verification. | | +| `agent_path_template` | A URL path portion format of Agent's SPIFFE ID. Describe in text/template format. | See [Agent Path Template](#agent-path-template) for details | +| `max_intermediates` | Maximum number of intermediate certificates allowed in the certificate chain. This limit helps prevent resource exhaustion attacks. | 4 | +| `max_rsa_key_size` | Maximum RSA key size in bits allowed in certificates. This limit helps prevent resource exhaustion attacks from excessively large keys. | 8192 | +| `verify_client_ip` | If `true`, validates the connecting peer's IP against the leaf certificate's IP SANs. Attestation fails if no SAN matches. Reflects the immediate peer - may not represent true client origin behind load balancers. | false | A sample configuration: diff --git a/doc/spire_agent.md b/doc/spire_agent.md index 73b99fd7ac..091ce15c4b 100644 --- a/doc/spire_agent.md +++ b/doc/spire_agent.md @@ -58,6 +58,7 @@ This may be useful for templating configuration files, for example across differ | `log_file` | File to write logs to | | | `log_level` | Sets the logging level <DEBUG|INFO|WARN|ERROR> | INFO | | `log_format` | Format of logs, <text|json> | Text | +| `log_selectors` | Workload selector prefixes allowed in diagnostic logs. Selector values can contain sensitive information; only configure prefixes whose values are acceptable to write to logs. Example: `["k8s:ns", "k8s:sa", "unix:user"]` | | | `log_source_location` | If true, logs include source file, line number, and method name fields (adds a bit of runtime cost) | false | | `profiling_enabled` | If true, enables a [net/http/pprof](https://pkg.go.dev/net/http/pprof) endpoint | false | | `profiling_freq` | Frequency of dumping profiling data to disk. Only enabled when `profiling_enabled` is `true` and `profiling_freq` > 0. | | @@ -77,13 +78,54 @@ This may be useful for templating configuration files, for example across differ | `x509_svid_cache_max_size` | Soft limit of max number of X509-SVIDs that would be stored in LRU cache | 1000 | | `jwt_svid_cache_max_size` | Hard limit of max number of JWT-SVIDs that would be stored in LRU cache | 1000 | -| experimental | Description | Default | -|:------------------------------|-----------------------------------------------------------------------------------------------------|-------------------------| -| `named_pipe_name` | Pipe name to bind the SPIRE Agent API named pipe (Windows only) | \spire-agent\public\api | -| `sync_interval` | Sync interval with SPIRE server with exponential backoff | 5 sec | -| `use_sync_authorized_entries` | Use SyncAuthorizedEntries API for periodically synchronization of authorized entries | true | -| `require_pq_kem` | Require use of a post-quantum-safe key exchange method for TLS handshakes | false | -| `jwt_svid_cache_hit_timeout` | Custom gRPC timeout (between 5 and 30s) when retrieving a NewJWTSVID when a valid JWT-SVID in cache | 30s | +| experimental | Description | Default | +| :---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | +| `named_pipe_name` | Pipe name to bind the SPIRE Agent API named pipe (Windows only) | \spire-agent\public\api | +| `sync_interval` | Sync interval with SPIRE server with exponential backoff | 5 sec | +| `use_sync_authorized_entries` | Use SyncAuthorizedEntries API for periodically synchronization of authorized entries | true | +| `require_pq_kem` | Require use of a post-quantum-safe key exchange method for TLS handshakes | false | +| `jwt_svid_cache_hit_timeout` | Custom gRPC timeout (between 5 and 30s) when retrieving a NewJWTSVID when a valid JWT-SVID in cache | 30s | +| `ratelimit` | Optional per-caller rate limiting for Workload API and SDS methods, enforced after workload attestation. See [Workload API Rate Limiting](#workload-api-rate-limiting) for details. | | +| `broker` | Optional SPIFFE Broker API endpoint configuration. See [SPIFFE Broker API](#spiffe-broker-api). | | + +### Workload API Rate Limiting + +The `ratelimit` configuration block enforces per-caller rate limits on Workload API and Envoy SDS methods to protect the agent from noisy-neighbor workloads and reconnection storms. + +This feature is **experimental** and lives under the `experimental` block. Its configuration may change or move in a future release (targeted for promotion or removal in 1.16/1.17). + +**Key resolution:** Rate limits are enforced after workload attestation. The caller's attested selector set (the full set of `type:value` pairs returned by the attestor) is used as the rate-limit key — all workloads with the same selector set share one token bucket, and workloads with different selector sets never interfere. Callers that cannot be attested (empty selector set) share a single `` bucket. The agent's own health probe is exempt from rate limiting. + +| ratelimit | Description | Default | +| :------------------- | ------------------------------------------------------------------------------------------- | ------------ | +| `fetch_x509_svid` | Max stream opens per second per selector set for `FetchX509SVID`. 0 disables rate limiting. | 0 (disabled) | +| `fetch_jwt_svid` | Max calls per second per selector set for `FetchJWTSVID`. 0 disables rate limiting. | 0 (disabled) | +| `fetch_x509_bundles` | Max stream opens per second per selector set for `FetchX509Bundles`. 0 disables. | 0 (disabled) | +| `fetch_jwt_bundles` | Max stream opens per second per selector set for `FetchJWTBundles`. 0 disables. | 0 (disabled) | +| `stream_secrets` | Max stream opens per second per selector set for SDS `StreamSecrets`. 0 disables. | 0 (disabled) | +| `fetch_secrets` | Max calls per second per selector set for SDS `FetchSecrets`. 0 disables. | 0 (disabled) | + +For streaming RPCs (`FetchX509SVID`, `FetchX509Bundles`, `FetchJWTBundles`, `StreamSecrets`), the rate limit is enforced at stream establishment (i.e., per reconnect), not per message. + +Example configuration: + +```hcl +agent { + # ... + experimental { + ratelimit { + fetch_x509_svid = 100 + fetch_jwt_svid = 500 + fetch_x509_bundles = 20 + fetch_jwt_bundles = 20 + stream_secrets = 50 + fetch_secrets = 50 + } + } +} +``` + +Calls exceeding the rate limit receive an `Unavailable` gRPC status code. ### Server Attestation @@ -353,6 +395,31 @@ Checks SPIRE agent's health. | `-socketPath` | Path to the SPIRE Agent API socket | /tmp/spire-agent/public/api.sock | | `-verbose` | Print verbose information | | +### `spire-agent logger get` + +Gets the current logging level of the SPIRE Agent. + +| Command | Action | Default | +|:--------------|:-----------------------------------------|:------------------------------------| +| `-socketPath` | Path to the SPIRE Agent Admin API socket | /tmp/spire-agent/private/admin.sock | + +### `spire-agent logger set` + +Sets the logging level of the SPIRE Agent. + +| Command | Action | Default | +|:--------------|:--------------------------------------------------------------------------|:------------------------------------| +| `-level` | The new log level, one of (panic, fatal, error, warn, info, debug, trace) | | +| `-socketPath` | Path to the SPIRE Agent Admin API socket | /tmp/spire-agent/private/admin.sock | + +### `spire-agent logger reset` + +Resets the logging level of the SPIRE Agent to the level it was set to at launch. + +| Command | Action | Default | +|:--------------|:-----------------------------------------|:------------------------------------| +| `-socketPath` | Path to the SPIRE Agent Admin API socket | /tmp/spire-agent/private/admin.sock | + ### `spire-agent validate` Validates a SPIRE agent configuration file. @@ -381,11 +448,17 @@ agent { telemetry { Prometheus { port = 1234 - #optional TLS for prometheus + # optional TLS for prometheus tls { - cert_file = "/path/to/cert.pem" - key_file = "/path/to/key.pem" - client_ca_file = "/path/to/ca.pem" # optional CA file for mTLS + use_spire_svid = true + authorized_spiffe_ids = [ + "spiffe://example.org/monitoring/prometheus", + ] + + # Alternatively, configure a web certificate directly: + # cert_file = "/path/to/cert.pem" + # key_file = "/path/to/key.pem" + # client_ca_file = "/path/to/ca.pem" } } } @@ -472,6 +545,140 @@ agent { } ``` +## SPIFFE Broker API + +The SPIFFE Broker API lets an authorized infrastructure component (a "broker") +obtain SVIDs and trust bundles on behalf of workloads that the broker +references, rather than the workload connecting to SPIRE Agent itself. It +implements the [SPIFFE Broker API](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Broker_API.md) +specification and is served from SPIRE Agent over a dedicated, mutually +authenticated endpoint that is distinct from the Workload API. + +> **Status:** experimental. The Broker API is being stabilized as the +> upstream SPIFFE specification matures; breaking changes may land before +> this configuration moves out of `experimental`. + +How it differs from the existing APIs: + +* The **Workload API** is invoked by a workload to obtain its own SVID, + and the workload is attested implicitly via its socket peer credentials. +* The **Delegated Identity API** lets an authorized delegate impersonate + any workload in the agent's scope; the delegate is responsible for + identifying the target workload (by selectors or PID). +* The **Broker API** is also delegate-style — a broker presents a + `WorkloadReference` identifying a workload, and the agent runs its + configured workload attestor stack against that reference to produce + selectors. Unlike the Delegated Identity API, the broker never + hand-supplies selectors; the agent's own attestor remains the source of + truth for what selectors apply to a reference. This makes the Broker + API safe to expose across the network (over mTLS) and not just over a + local UDS. + +The specification currently defines two reference types: + +* `WorkloadPIDReference` — a process ID on the agent's node. Per + [SPIFFE Broker API §3.1.2](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Broker_API.md#312-reference-scope), + this is a local reference and should not cross network boundaries. + SPIRE Agent enforces this server-side via each broker's + `allowed_reference_types[].allow_over_tcp` setting (see + [Configuration](#configuration)), which defaults to false: TCP requests + are denied unless the operator explicitly opts the reference type in. +* `KubernetesObjectReference` — an arbitrary Kubernetes object identified + by `type` (resource and group) plus `key` (namespace and name) and/or + `uid`. SPIRE Agent's `k8s` workload attestor can support this reference + type when its `allowed_reference_types` includes + `type.googleapis.com/spiffe.broker.KubernetesObjectReference`, and emits + selectors describing the object (`k8s:pod-name`, etc. for pods; or the + generic `k8s:resource`, `k8s:kind`, `k8s:name`, ... for any other + resource). + +When the `k8s` workload attestor handles Broker API `AttestReference` calls, +its plugin-level `experimental.broker` block is required for every reference type. Set +`experimental.broker.access_policy = "enforced"` to make the attestor run Kubernetes +`SubjectAccessReview` checks against the resolved pod for +`WorkloadPIDReference`, and against the referenced object for +`KubernetesObjectReference`. Set `experimental.broker.access_policy = "permissive"` to +skip those checks. The setting is required; there is no default. + +### Transport and authentication + +The Broker API is gated by mutual TLS using X.509-SVIDs. A broker is +expected to obtain its own SVID via the Workload API first, then use that +SVID to dial the broker endpoint. The agent presents its own SVID and +verifies the broker's certificate against the configured per-broker +allowlist. + +Per the spec, every request must carry the `broker.spiffe.io: true` gRPC +metadata header as an SSRF mitigation. SPIRE Agent rejects requests that +omit it with `InvalidArgument`. This applies to _every_ method, including +gRPC server reflection (which is only served over UDS). Tools like +`grpcurl`/`grpcui` do not send the header by default, so reflection calls +must set it explicitly, e.g.: + +```sh +grpcurl -unix -rpc-header 'broker.spiffe.io: true' /path/to/broker.sock list +``` + +### Configuration + +The endpoint is enabled by adding a `broker` block under `experimental` +in the agent config. At least one of `socket_path` (POSIX-only UDS) or +`bind_address` (TCP, all platforms) must be set; both may be set +simultaneously. + +The `socket_path` must not live in the same directory as the Workload +API socket — SPIRE Agent will refuse to start otherwise. + +Each entry in `brokers` enumerates an authorized broker's SPIFFE ID +(cross-trust-domain identities are allowed) and the reference types it +may use. The `allowed_reference_types` list is required and must contain +at least one object. Each object requires `type_url`, the verbatim +protobuf type URL. Set `allow_over_tcp = true` on an entry only when that +reference type is safe over the network; it defaults to false, so UDS is +allowed but TCP is denied. Use a single object with `type_url = "*"` to +allow any reference type the agent's attestor stack understands. + +```hcl +agent { + trust_domain = "example.org" + ... + experimental { + broker { + socket_path = "/run/spire/broker-sockets/broker.sock" # POSIX UDS + bind_address = "0.0.0.0:8443" # optional TCP + + brokers = [ + { + id = "spiffe://example.org/some/broker" + allowed_reference_types = [ + { + type_url = "type.googleapis.com/spiffe.broker.WorkloadPIDReference" + }, + { + type_url = "type.googleapis.com/spiffe.broker.KubernetesObjectReference" + allow_over_tcp = true + }, + ] + }, + { + id = "spiffe://example.org/wildcard/broker" + allowed_reference_types = [ + { + type_url = "*" + }, + ] + }, + ] + } + } +} +``` + +Brokers whose ID isn't in this list are rejected at the TLS layer. +Brokers in the list but using a reference type outside their +`allowed_reference_types` are rejected with `PermissionDenied` at the +gRPC layer. + ## Envoy SDS Support SPIRE agent has support for the [Envoy](https://envoyproxy.io) [Secret Discovery Service](https://www.envoyproxy.io/docs/envoy/latest/configuration/security/secret) (SDS). diff --git a/doc/spire_server.md b/doc/spire_server.md index 0254f425c7..11edf4de4a 100644 --- a/doc/spire_server.md +++ b/doc/spire_server.md @@ -83,7 +83,8 @@ This may be useful for templating configuration files, for example across differ | `profiling_freq` | Frequency of dumping profiling data to disk. Only enabled when `profiling_enabled` is `true` and `profiling_freq` > 0. | | | `profiling_names` | List of profile names that will be dumped to disk on each profiling tick, see [Profiling Names](#profiling-names) | | | `profiling_port` | Port number of the [net/http/pprof](https://pkg.go.dev/net/http/pprof) endpoint. Only used when `profiling_enabled` is `true`. | | -| `prune_attested_nodes_expired_for` | Enables periodic purging of attested node records with expired SVIDs where the expiry time further in the past than the specified duration. Non-reattestable nodes are not pruned unless `prune_tofu_nodes` is set to `true`. Banned nodes are not pruned. | | +| `prune_attested_nodes_expired_for` | Enables periodic purging of attested node records with expired SVIDs where the expiry time further in the past than the specified duration. Non-reattestable nodes are not pruned unless `prune_tofu_nodes` is set to `true`. Banned nodes are not pruned. Join-token nodes also cascade-delete the `CreateJoinToken` alias entry on prune or `agent evict`/`purge` (forward-only). | | +| `prune_attested_nodes_batch_size` | Maximum number of expired attested nodes pruned per cycle. Only applies when `prune_attested_nodes_expired_for` is set. | 1000 | | `prune_tofu_nodes` | Includes expired TOFU nodes into consideration for pruning. This does not affect banned nodes, which are not pruned. | false | | `ratelimit` | Rate limiting configurations, usually used when the server is behind a load balancer (see below) | | | `socket_path` | Path to bind the SPIRE Server API socket to (Unix only) | /tmp/spire-server/private/api.sock | @@ -637,6 +638,31 @@ Checks SPIRE server's health. | `-socketPath` | Path to the SPIRE Server API socket | /tmp/spire-server/private/api.sock | | `-verbose` | Print verbose information | | +### `spire-server logger get` + +Gets the current logging level of the SPIRE Server. + +| Command | Action | Default | +|:--------------|:------------------------------------|:-----------------------------------| +| `-socketPath` | Path to the SPIRE Server API socket | /tmp/spire-server/private/api.sock | + +### `spire-server logger set` + +Sets the logging level of the SPIRE Server. + +| Command | Action | Default | +|:--------------|:--------------------------------------------------------------------------|:-----------------------------------| +| `-level` | The new log level, one of (panic, fatal, error, warn, info, debug, trace) | | +| `-socketPath` | Path to the SPIRE Server API socket | /tmp/spire-server/private/api.sock | + +### `spire-server logger reset` + +Resets the logging level of the SPIRE Server to the level it was set to at launch. + +| Command | Action | Default | +|:--------------|:------------------------------------|:-----------------------------------| +| `-socketPath` | Path to the SPIRE Server API socket | /tmp/spire-server/private/api.sock | + ### `spire-server validate` Validates a SPIRE server configuration file. Arguments are the same as `spire-server run`. @@ -827,11 +853,17 @@ server { telemetry { Prometheus { port = 1234 - #optional TLS for prometheus + # optional TLS for prometheus tls { - cert_file = "/path/to/cert.pem" - key_file = "/path/to/key.pem" - client_ca_file = "/path/to/ca.pem" # optional CA file for mTLS + use_spire_svid = true + authorized_spiffe_ids = [ + "spiffe://example.org/monitoring/prometheus", + ] + + # Alternatively, configure a web certificate directly: + # cert_file = "/path/to/cert.pem" + # key_file = "/path/to/key.pem" + # client_ca_file = "/path/to/ca.pem" } } } diff --git a/doc/telemetry/telemetry.md b/doc/telemetry/telemetry.md index d580f98cf5..fe7b819551 100644 --- a/doc/telemetry/telemetry.md +++ b/doc/telemetry/telemetry.md @@ -102,6 +102,7 @@ The following metrics are emitted: | Sample | `workload_api`, `discovered_selectors` | | The number of selectors discovered during a workload attestation process. | | Call Counter | `workload_api`, `workload_attestation` | | The Workload API is performing a workload attestation. | | Call Counter | `workload_api`, `workload_attestor` | `attestor` | The Workload API is invoking a given attestor. | +| Counter | `workload_api`, `rate_limit_exceeded` | `method` | A Workload API or SDS request was rejected due to per-selector-set rate limiting. | | Gauge | `started` | `version`, `trust_domain_id` | Information about the Agent. | | Gauge | `uptime_in_ms` | | The uptime of the Agent in milliseconds. | | Counter | `debug_api`, `connection` | | The Debug API has successfully established a connection. | diff --git a/doc/telemetry/telemetry_config.md b/doc/telemetry/telemetry_config.md index 1dd25a303f..69cc63a87b 100644 --- a/doc/telemetry/telemetry_config.md +++ b/doc/telemetry/telemetry_config.md @@ -34,10 +34,21 @@ You may use all, some, or none of the collectors. The following collectors suppo ### `Prometheus` -| Configuration | Type | Description | -|---------------|----------|------------------------------------| -| `host` | `string` | Prometheus exporter listen address | -| `port` | `int` | Prometheus exporter listen port | +| Configuration | Type | Description | +|---------------|----------|-----------------------------------------------| +| `host` | `string` | Prometheus exporter listen address | +| `port` | `int` | Prometheus exporter listen port | +| `tls` | `object` | TLS configuration for the Prometheus exporter | + +#### `Prometheus.tls` + +| Configuration | Type | Description | +| ------------- | ---- | ----------- | +| `cert_file` | `string` | Path to the PEM-encoded certificate for the Prometheus exporter. Must be set with `key_file` unless `use_spire_svid` is enabled | +| `key_file` | `string` | Path to the PEM-encoded private key for the Prometheus exporter. Must be set with `cert_file` unless `use_spire_svid` is enabled | +| `client_ca_file` | `string` | Optional path to the PEM-encoded CA bundle used to verify client certificates for mTLS. Cannot be combined with `authorized_spiffe_ids` | +| `use_spire_svid` | `bool` | When `true`, serve the Prometheus endpoint with the current SPIRE SVID instead of `cert_file` and `key_file` | +| `authorized_spiffe_ids` | `list(string)` | Optional list of SPIFFE IDs allowed to connect to the Prometheus endpoint. Requires SPIRE trust bundles and cannot be combined with `client_ca_file` | ### `DogStatsd` @@ -64,11 +75,17 @@ Here is a sample configuration: telemetry { Prometheus { port = 9988 - #optional TLS for prometheus + # optional TLS for prometheus tls { - cert_file = "/path/to/cert.pem" - key_file = "/path/to/key.pem" - client_ca_file = "/path/to/ca.pem" # optional CA file for mTLS + use_spire_svid = true + authorized_spiffe_ids = [ + "spiffe://example.org/monitoring/prometheus", + ] + + # Alternatively, configure a web certificate directly: + # cert_file = "/path/to/cert.pem" + # key_file = "/path/to/key.pem" + # client_ca_file = "/path/to/ca.pem" # optional CA file for mTLS } } diff --git a/go.mod b/go.mod index 7d9d7eda9d..d3c9f20533 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,16 @@ module github.com/spiffe/spire -go 1.26.3 +go 1.26.4 require ( cloud.google.com/go/iam v1.11.0 cloud.google.com/go/kms v1.31.0 cloud.google.com/go/secretmanager v1.20.0 - cloud.google.com/go/security v1.24.0 - cloud.google.com/go/storage v1.62.0 - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 + cloud.google.com/go/security v1.25.0 + cloud.google.com/go/storage v1.62.2 + dario.cat/mergo v1.0.2 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7 v7.3.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9 v9.0.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.10.0 @@ -20,26 +21,26 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 github.com/Microsoft/go-winio v0.6.2 github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 - github.com/aws/aws-sdk-go-v2 v1.41.7 - github.com/aws/aws-sdk-go-v2/config v1.32.14 - github.com/aws/aws-sdk-go-v2/credentials v1.19.14 - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 + github.com/aws/aws-sdk-go-v2 v1.42.0 + github.com/aws/aws-sdk-go-v2/config v1.32.18 + github.com/aws/aws-sdk-go-v2/credentials v1.19.17 + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.0 github.com/aws/aws-sdk-go-v2/service/acmpca v1.47.0 - github.com/aws/aws-sdk-go-v2/service/autoscaling v1.66.0 - github.com/aws/aws-sdk-go-v2/service/ec2 v1.304.0 - github.com/aws/aws-sdk-go-v2/service/eks v1.84.0 - github.com/aws/aws-sdk-go-v2/service/iam v1.53.1 - github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 + github.com/aws/aws-sdk-go-v2/service/autoscaling v1.67.0 + github.com/aws/aws-sdk-go-v2/service/ec2 v1.308.0 + github.com/aws/aws-sdk-go-v2/service/eks v1.87.0 + github.com/aws/aws-sdk-go-v2/service/iam v1.54.1 + github.com/aws/aws-sdk-go-v2/service/kms v1.53.1 github.com/aws/aws-sdk-go-v2/service/organizations v1.51.1 + github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.33.3 github.com/aws/aws-sdk-go-v2/service/rolesanywhere v1.23.0 - github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 - github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0 - github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 - github.com/aws/smithy-go v1.25.1 + github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.42.0 + github.com/aws/aws-sdk-go-v2/service/sts v1.43.0 + github.com/aws/smithy-go v1.27.2 github.com/blang/semver/v4 v4.0.0 github.com/cenkalti/backoff/v4 v4.3.0 - github.com/docker/docker v28.5.2+incompatible github.com/envoyproxy/go-control-plane/envoy v1.37.0 github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa github.com/go-jose/go-jose/v4 v4.1.4 @@ -49,54 +50,53 @@ require ( github.com/gogo/status v1.1.1 github.com/google/btree v1.1.3 github.com/google/go-cmp v0.7.0 - github.com/google/go-containerregistry v0.21.6 + github.com/google/go-containerregistry v0.21.7 github.com/google/go-tpm v0.9.8 - github.com/google/go-tpm-tools v0.4.8 + github.com/google/go-tpm-tools v0.4.9 github.com/googleapis/gax-go/v2 v2.22.0 github.com/gorilla/handlers v1.5.2 github.com/hashicorp/go-hclog v1.6.3 - github.com/hashicorp/go-metrics v0.5.4 + github.com/hashicorp/go-metrics v0.6.0 github.com/hashicorp/go-plugin v1.8.0 github.com/hashicorp/hcl v1.0.1-vault-7 github.com/hashicorp/vault/api v1.23.0 github.com/hashicorp/vault/sdk v0.25.1 - github.com/imdario/mergo v0.3.16 - github.com/imkira/go-observer v1.0.3 - github.com/jackc/pgx/v5 v5.9.2 + github.com/jackc/pgx/v5 v5.10.0 github.com/jinzhu/gorm v1.9.16 github.com/lib/pq v1.12.3 - github.com/mattn/go-sqlite3 v1.14.44 + github.com/mattn/go-sqlite3 v1.14.47 github.com/mitchellh/cli v1.1.5 - github.com/open-policy-agent/opa v1.16.2 + github.com/moby/moby/api v1.55.0 + github.com/moby/moby/client v0.5.0 + github.com/open-policy-agent/opa v1.17.1 github.com/pires/go-proxyproto v0.12.0 github.com/prometheus/client_golang v1.23.2 - github.com/shirou/gopsutil/v4 v4.26.4 - github.com/sigstore/cosign/v3 v3.0.6 + github.com/shirou/gopsutil/v4 v4.26.6 + github.com/sigstore/cosign/v3 v3.1.1 github.com/sigstore/rekor v1.5.2 - github.com/sigstore/sigstore v1.10.6 + github.com/sigstore/sigstore v1.10.8 github.com/sirupsen/logrus v1.9.4 github.com/smallstep/pkcs7 v0.2.1 - github.com/spiffe/go-spiffe/v2 v2.6.0 + github.com/spiffe/go-spiffe/v2 v2.8.1 github.com/spiffe/spire-api-sdk v1.2.5-0.20260428072036-00f73a61093a - github.com/spiffe/spire-plugin-sdk v1.4.4-0.20251120194148-791bbd274dc7 + github.com/spiffe/spire-plugin-sdk v1.4.4-0.20260617144146-5dcde407c4d1 github.com/stretchr/testify v1.11.1 github.com/uber-go/tally/v4 v4.1.17 github.com/valyala/fastjson v1.6.10 - golang.org/x/crypto v0.52.0 + golang.org/x/crypto v0.53.0 golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 - golang.org/x/net v0.55.0 - golang.org/x/sync v0.20.0 - golang.org/x/sys v0.45.0 + golang.org/x/net v0.56.0 + golang.org/x/sync v0.21.0 + golang.org/x/sys v0.46.0 golang.org/x/time v0.15.0 - google.golang.org/api v0.282.0 - google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 + google.golang.org/api v0.286.0 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af - k8s.io/api v0.36.1 - k8s.io/apimachinery v0.36.1 - k8s.io/client-go v0.36.1 - k8s.io/kube-aggregator v0.36.1 - k8s.io/mount-utils v0.36.1 + k8s.io/api v0.36.2 + k8s.io/apimachinery v0.36.2 + k8s.io/client-go v0.36.2 + k8s.io/kube-aggregator v0.36.2 sigs.k8s.io/controller-runtime v0.24.1 ) @@ -106,17 +106,16 @@ require ( cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect - cloud.google.com/go/longrunning v0.9.0 // indirect - cloud.google.com/go/monitoring v1.24.3 // indirect - dario.cat/mergo v1.0.2 // indirect + cloud.google.com/go/longrunning v1.0.0 // indirect + cloud.google.com/go/monitoring v1.25.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.2.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect - github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1 // indirect - github.com/DataDog/datadog-go v3.2.0+incompatible // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect + github.com/DataDog/datadog-go v4.8.3+incompatible // indirect + github.com/GoogleCloudPlatform/confidential-space/server v0.0.0-20260522213940-e5c6d01a3007 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect @@ -125,18 +124,17 @@ require ( github.com/agnivade/levenshtein v1.2.1 // indirect github.com/armon/go-radix v1.0.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bgentry/speakeasy v0.2.0 // indirect github.com/blang/semver v3.5.1+incompatible // indirect @@ -145,14 +143,14 @@ require ( github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect - github.com/coreos/go-oidc/v3 v3.17.0 // indirect + github.com/coreos/go-oidc/v3 v3.18.0 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/cli v29.4.3+incompatible // indirect + github.com/docker/cli v29.5.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.5 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect @@ -163,23 +161,25 @@ require ( github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fatih/color v1.19.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/go-chi/chi/v5 v5.2.5 // indirect + github.com/go-chi/chi/v5 v5.3.0 // indirect github.com/go-jose/go-jose/v3 v3.0.5 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/go-openapi/analysis v0.25.0 // indirect + github.com/go-openapi/analysis v0.25.2 // indirect github.com/go-openapi/errors v0.22.7 // indirect - github.com/go-openapi/jsonpointer v0.22.5 // indirect - github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.6 // indirect github.com/go-openapi/loads v0.23.3 // indirect - github.com/go-openapi/runtime v0.29.4 // indirect - github.com/go-openapi/spec v0.22.4 // indirect - github.com/go-openapi/strfmt v0.26.2 // indirect + github.com/go-openapi/runtime v0.32.3 // indirect + github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect + github.com/go-openapi/spec v0.22.5 // indirect + github.com/go-openapi/strfmt v0.26.3 // indirect github.com/go-openapi/swag v0.26.0 // indirect github.com/go-openapi/swag/cmdutils v0.26.0 // indirect - github.com/go-openapi/swag/conv v0.26.0 // indirect + github.com/go-openapi/swag/conv v0.26.1 // indirect github.com/go-openapi/swag/fileutils v0.26.0 // indirect github.com/go-openapi/swag/jsonname v0.26.0 // indirect github.com/go-openapi/swag/jsonutils v0.26.0 // indirect @@ -187,9 +187,9 @@ require ( github.com/go-openapi/swag/mangling v0.26.0 // indirect github.com/go-openapi/swag/netutils v0.26.0 // indirect github.com/go-openapi/swag/stringutils v0.26.0 // indirect - github.com/go-openapi/swag/typeutils v0.26.0 // indirect + github.com/go-openapi/swag/typeutils v0.26.1 // indirect github.com/go-openapi/swag/yamlutils v0.26.0 // indirect - github.com/go-openapi/validate v0.25.2 // indirect + github.com/go-openapi/validate v0.25.3 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.6 // indirect @@ -202,13 +202,14 @@ require ( github.com/google/certificate-transparency-go v1.3.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-configfs-tsm v0.3.3-0.20240919001351-b4b5b84fdcbc // indirect + github.com/google/go-eventlog v0.0.3-0.20260416001248-6807b85eecf0 // indirect github.com/google/go-sev-guest v0.14.0 // indirect github.com/google/go-tdx-guest v0.3.2-0.20250814004405-ffb0869e6f4d // indirect github.com/google/logger v1.1.1 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect @@ -221,7 +222,7 @@ require ( github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/huandu/xstrings v1.5.0 // indirect - github.com/in-toto/attestation v1.1.2 // indirect + github.com/in-toto/attestation v1.2.0 // indirect github.com/in-toto/in-toto-golang v0.11.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -236,9 +237,9 @@ require ( github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect - github.com/lestrrat-go/jwx/v3 v3.1.0 // indirect + github.com/lestrrat-go/jwx/v3 v3.1.1 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect - github.com/letsencrypt/boulder v0.20260223.0 // indirect + github.com/letsencrypt/boulder v0.20260309.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -247,12 +248,9 @@ require ( github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/sys/atomicwriter v0.1.0 // indirect - github.com/moby/sys/mountinfo v0.7.2 // indirect github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect - github.com/morikuni/aec v1.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 // indirect github.com/oklog/run v1.2.0 // indirect @@ -276,52 +274,54 @@ require ( github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sigstore/protobuf-specs v0.5.1 // indirect - github.com/sigstore/rekor-tiles/v2 v2.2.1 // indirect - github.com/sigstore/sigstore-go v1.1.4 // indirect - github.com/sigstore/timestamp-authority/v2 v2.0.6 // indirect + github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443 // indirect + github.com/sigstore/sigstore-go v1.2.0 // indirect + github.com/sigstore/timestamp-authority/v2 v2.1.2 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect github.com/tchap/go-patricia/v2 v2.3.3 // indirect github.com/theupdateframework/go-tuf v0.7.0 // indirect - github.com/theupdateframework/go-tuf/v2 v2.4.1 // indirect + github.com/theupdateframework/go-tuf/v2 v2.4.2 // indirect github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect - github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c // indirect + github.com/transparency-dev/formats v0.1.1 // indirect github.com/transparency-dev/merkle v0.0.2 // indirect github.com/twmb/murmur3 v1.1.8 // indirect - github.com/vektah/gqlparser/v2 v2.5.32 // indirect + github.com/vektah/gqlparser/v2 v2.5.33 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/yashtewari/glob-intersection v0.2.0 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/sdk v1.43.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.36.0 // indirect + golang.org/x/mod v0.37.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.37.0 // indirect - google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - gotest.tools/v3 v3.5.2 // indirect + k8s.io/apiextensions-apiserver v0.36.0 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect diff --git a/go.sum b/go.sum index ccf1247a7c..c6a651b908 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,5 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= @@ -13,31 +12,33 @@ cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= -cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= -cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= -cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY= -cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E= -cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= -cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/logging v1.14.0 h1:xpPpY8cVT6n9DgIRgrWyE+YEsGlO/994pWnbc7o5Eh4= +cloud.google.com/go/logging v1.14.0/go.mod h1:jmI+Try/fZeOTOAer3wVYOuPf9WX9PyzhlSDoBAi4HM= +cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= +cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= +cloud.google.com/go/monitoring v1.25.0 h1:HnsTIOxTN6BCSkt1P/Im23r1m7MHTTpmSYCzPkW7NK4= +cloud.google.com/go/monitoring v1.25.0/go.mod h1:wlj6rX+JGyusw/8+2duW4cJ6kmDHGmde3zMTJuG3Jpc= cloud.google.com/go/secretmanager v1.20.0 h1:GjE3NoyFXo7ipRPy26PMmg4oRX1Ra8fswH45r16rWV0= cloud.google.com/go/secretmanager v1.20.0/go.mod h1:9OmSuOeiiUicANglrbdKWSnT3gYkRcXuUQDk7dDW0zU= -cloud.google.com/go/security v1.24.0 h1:0xkc4JbFF6xCzMRpr5J5U/0mojdRQ6N0Uk0feGctViI= -cloud.google.com/go/security v1.24.0/go.mod h1:XaB3p0SE7v2bBitsLBb1hM6R8/oI/k/IujpXFJalFK0= -cloud.google.com/go/storage v1.62.0 h1:w2pQJhpUqVerMON45vatE2FpCYsNTf7OHjkn6ux5mMU= -cloud.google.com/go/storage v1.62.0/go.mod h1:T5hz3qzcpnxZ5LdKc7y8Tw7lh4v9zeeVyrD/cLJAzZU= -cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= -cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +cloud.google.com/go/security v1.25.0 h1:U7Op1u7GSbmqEBtQEqxAKG7jMpMFNJ9mTFf+E0HYTJY= +cloud.google.com/go/security v1.25.0/go.mod h1:xKPO7XBfUtgjfzPJeznEhI0gp/ZRJt/ZbWtuMYMeUDk= +cloud.google.com/go/storage v1.62.2 h1:WgR4U9n7bIzXkkVnwPKKE8bkaKUNsHG+0MAAlh9DGU4= +cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= +cloud.google.com/go/trace v1.12.0 h1:XvWHYfr9q88cX4pZyou6qCcSagnuASyUq2ej1dB6NzQ= +cloud.google.com/go/trace v1.12.0/go.mod h1:TOYfyeoyCGsSH0ifXD6Aius24uQI9xV3RyvOdljFIyg= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= @@ -64,14 +65,14 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1 h1:edShSHV3DV90+kt+CMaEXEzR9QF7wFrPJxVGz2blMIU= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= -github.com/DataDog/datadog-go v3.2.0+incompatible h1:qSG2N4FghB1He/r2mFrWKCaL7dXCilEuNEeAn20fdD4= -github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/DataDog/datadog-go v4.8.3+incompatible h1:fNGaYSuObuQb5nzeTQqowRAd9bpDIRRV4/gUtIBjh8Q= +github.com/DataDog/datadog-go v4.8.3+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/GoogleCloudPlatform/cloudsql-proxy v1.38.0 h1:zkV+eQwNiqt5een7GmM6A5gN61ci/CeFkroL6UP14mo= github.com/GoogleCloudPlatform/cloudsql-proxy v1.38.0/go.mod h1:fRGeKSTU29df8r79rT5SyJ0HFRWQePP03UjcQnt8cy8= -github.com/GoogleCloudPlatform/confidential-space/server v0.0.0-20260307011055-895ec9019dd7 h1:Iz7wjnn93xcmPlUS/9ue7CeyH7yvcxHAXKu+2lE2/is= -github.com/GoogleCloudPlatform/confidential-space/server v0.0.0-20260307011055-895ec9019dd7/go.mod h1:sNFt/HcARjGxR3/2s7hwlqvHlUzXdaCiS62u7A4rnHg= +github.com/GoogleCloudPlatform/confidential-space/server v0.0.0-20260522213940-e5c6d01a3007 h1:DoeEFwEGBdqcawmpiWtSsSVVZ+wk3zpqvcvssO2JLmY= +github.com/GoogleCloudPlatform/confidential-space/server v0.0.0-20260522213940-e5c6d01a3007/go.mod h1:s8F0JYEods/WL03WxZaGsWCnumZeeLD+WKHzspOV9u0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= @@ -95,11 +96,6 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 h1:MzBOUgng9orim59UnfUTLRjMpd09C5uEVQ6RPGeCaVI= @@ -109,74 +105,69 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= -github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= -github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= -github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= -github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= -github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI= -github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo= -github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= +github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= +github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls= +github.com/aws/aws-sdk-go-v2/config v1.32.18 h1:Hcia46bxhGgF3BaSnG8nSNCWmqTK6bj9xN9/FJ3WK6Q= +github.com/aws/aws-sdk-go-v2/config v1.32.18/go.mod h1:zEjCAYmxqDadH1WX8CdBvmLKhUEUVFgKRQG38zjDmrY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.17 h1:gP2nkGsS+KMvF/jfFz2Vv2qiiOqWKyPACSzPsqHgoW8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.17/go.mod h1:Bsew3S/moG5iT77giPj1q8wb/s0RE5/QfH+ASjYtuQc= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.0 h1:SE3IDYzg2WwsAmkxSnEGuW/Bek8js245j1lGwZJpl1E= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.0/go.mod h1:duFNXIVHPkyfllpU5GuJ+QoiETTsDWSOMvpOEcy5Kss= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY= github.com/aws/aws-sdk-go-v2/service/acmpca v1.47.0 h1:NS9lpox+4E9bJdsHDKFoRQqEmplD2wlcZR0rryn2z1s= github.com/aws/aws-sdk-go-v2/service/acmpca v1.47.0/go.mod h1:qFP+Zv26pVlLajTm293Ga9I82NRjnrTpXtMtkFFn5xc= -github.com/aws/aws-sdk-go-v2/service/autoscaling v1.66.0 h1:XhJjE+BQ1pWyuAb6waHKx9DjDZdmdpp2+B6SP97s/M8= -github.com/aws/aws-sdk-go-v2/service/autoscaling v1.66.0/go.mod h1:Lg8BJb1TOzVTJ6RFfkJ9zyI/XFcjcfZem+Iu4PeQxPE= -github.com/aws/aws-sdk-go-v2/service/ec2 v1.304.0 h1:wZthLlYdKxBo7NpWLbl0A/8DB/QNDB+8RJa9WboK9Q0= -github.com/aws/aws-sdk-go-v2/service/ec2 v1.304.0/go.mod h1:Y95W0Hm6FYLPa6o0hbnJ+sWgmdc4ifcLFjGkdobWVhY= -github.com/aws/aws-sdk-go-v2/service/eks v1.84.0 h1:U9HMTDPdZtkCOTE8ACbHQJmXGBKP7/mBds7M1JbUZH0= -github.com/aws/aws-sdk-go-v2/service/eks v1.84.0/go.mod h1:JQcyECIV9iZHm+GMrWn1pTPTJYRavOVsqPvlCbjt+Fg= -github.com/aws/aws-sdk-go-v2/service/iam v1.53.1 h1:xNCUk9XN6Pa9PyzbEfzgRpvEIVlqtth402yjaWvNMu4= -github.com/aws/aws-sdk-go-v2/service/iam v1.53.1/go.mod h1:GNQZL4JRSGH6L0/SNGOtffaB1vmlToYp3KtcUIB0NhI= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8= -github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= -github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= +github.com/aws/aws-sdk-go-v2/service/autoscaling v1.67.0 h1:EMGuR9gNPuVJgJLswfZ4X1SZr//NrcS/P68lm6Sd9OY= +github.com/aws/aws-sdk-go-v2/service/autoscaling v1.67.0/go.mod h1:Rhx3203rfa7exTsqc5Yt+YZcH8/kZH0F0vKaYMeFWNM= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.308.0 h1:xBP+yWpveXD/PxK7HRMcoG6yj1vdOjSahAg4qPomF+0= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.308.0/go.mod h1:8mrDF7OtbuL0QpwP4YCvLuoOE4/5lL7D33MXgp069/Y= +github.com/aws/aws-sdk-go-v2/service/eks v1.87.0 h1:bftLltXNWmNr9ed3CaQnVlzNPTNTFdHguNhIsZF6DxM= +github.com/aws/aws-sdk-go-v2/service/eks v1.87.0/go.mod h1:rbIASs+SfCDUXx2EdfMkNpDGptlW8hvMZ9AawRiUBqE= +github.com/aws/aws-sdk-go-v2/service/iam v1.54.1 h1:tGMIuCzwPn+khX04Q4x+qObujZMeviOHjorVTlMGyuM= +github.com/aws/aws-sdk-go-v2/service/iam v1.54.1/go.mod h1:yqieKkvU95VbVxcfTpq3cNyrjY4pWLrbK4eUL5/EZM8= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw= +github.com/aws/aws-sdk-go-v2/service/kms v1.53.1 h1:ZG4esUs5NrDr6lwKDZLE+746tq7nxS8PacNfHA9mOjk= +github.com/aws/aws-sdk-go-v2/service/kms v1.53.1/go.mod h1:f8U49SXXH38WX8B1oFXG8igfV+MZ5mgM9b/trTlBf2E= github.com/aws/aws-sdk-go-v2/service/organizations v1.51.1 h1:5hM1jQjIzEiu07ZqQ8iI4sC+06C8a+idNtytO65dhAw= github.com/aws/aws-sdk-go-v2/service/organizations v1.51.1/go.mod h1:urLFj1twuR/h5T0wN/2/kmY1gxBFa1tTKr+c60lZ2fA= +github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.33.3 h1:cbpUm/70ixkgEnoSjn0QlEr2h0sRxPePELoF2DnQWHc= +github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.33.3/go.mod h1:X1hs6YxHBTOd8urqr4UY/630+PkthkarMTAn66Hmeo0= github.com/aws/aws-sdk-go-v2/service/rolesanywhere v1.23.0 h1:BD6q8KEiom1qJpDC7kmBrKwMXCRkuSy7JXWPhQThFkI= github.com/aws/aws-sdk-go-v2/service/rolesanywhere v1.23.0/go.mod h1:kJgqmGmlOPqTe9cntPrSgMdIQ3cAama8TAW+NKaDCzE= -github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU= -github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4= -github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0 h1:vL6rQXcGtFv9q/9eRPdI+lL+dvTm7xKGZYSHEvmrpDk= -github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0/go.mod h1:QwEDLD+7EukuEUnbWtiNE8LhgvvmhjZoi4XAppYPtyc= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= -github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= -github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 h1:ta8csKy5vN91F3i5gGR85lFV0srBqySEji7Jroes6rE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.42.0 h1:EG0w0W14viU00v9JwVnictgn5DZn/VHf9mlrl6iyp9o= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.42.0/go.mod h1:IMjIzAyHHxe5fLSdDHxAGiQsWD+pnhqOUnwZdSHhYL0= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 h1:nDARhv/oF55bcxF7rCI/4PDxOKnVXVWwDuDwCs2I2SQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.0 h1:HN43uYvF8D2NtaPTeE4L3HMvXgd+RtqEqrea4uUztkM= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.0/go.mod h1:qzhDlfw1BRYyoNofevemNzrlRpUXa0BJamWx7klrR8w= +github.com/aws/smithy-go v1.27.2 h1:y9NPmSE6am6LjEFPfqHqG/jJk7AauQvhCJONKh7kpzk= +github.com/aws/smithy-go v1.27.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= @@ -188,37 +179,30 @@ github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= -github.com/bytecodealliance/wasmtime-go/v43 v43.0.2 h1:EZJlEpDanv6j/Y5Mcl2ndMjgK5Tw2QVDTJCzmLWNozg= -github.com/bytecodealliance/wasmtime-go/v43 v43.0.2/go.mod h1:EhGDFKNmDpLc6l4Cq+U2y8zu3NM6Uiek+He4yebZFHs= +github.com/bytecodealliance/wasmtime-go/v44 v44.0.0 h1:WRZXnLPIer/TWs5aYPaMlmVcOlzmR6Ur6wjLRIQOhTQ= +github.com/bytecodealliance/wasmtime-go/v44 v44.0.0/go.mod h1:GP93piU+39CoFVCQ5xfHrPOUtL0APlMnkbblJ2d3YY0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= -github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= -github.com/containerd/containerd v1.7.30 h1:/2vezDpLDVGGmkUXmlNPLCCNKHJ5BbC5tJB5JNzQhqE= -github.com/containerd/containerd v1.7.30/go.mod h1:fek494vwJClULlTpExsmOyKCMUAbuVjlFsJQc4/j44M= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= -github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= -github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= -github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= @@ -244,10 +228,8 @@ github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1G github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/cli v29.4.3+incompatible h1:u+UliYm2J/rYrIh2FqHQg32neRG8GjbvNuwQRTzGspU= -github.com/docker/cli v29.4.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= -github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs= +github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= @@ -270,6 +252,8 @@ github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMD github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= @@ -287,24 +271,18 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa h1:RDBNVkRviHZtvDvId8XSGPu3rmpmSe+wKRcEWNgsfWU= github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= -github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -314,28 +292,30 @@ github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-openapi/analysis v0.25.0 h1:EnjAq1yO8wEO9HbPmY8vLPEIkdZuuFhCAKBPvCB7bCs= -github.com/go-openapi/analysis v0.25.0/go.mod h1:5WFTRE43WLkPG9r9OtlMfqkkvUTYLVVCIxLlEpyF8kE= +github.com/go-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw= +github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo= github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= -github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= -github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= -github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= -github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= +github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= -github.com/go-openapi/runtime v0.29.4 h1:k2lDxrGoSAJRdhFG2tONKMpkizY/4X1cciSdtzk4Jjo= -github.com/go-openapi/runtime v0.29.4/go.mod h1:K0k/2raY6oqXJnZAgWJB2i/12QKrhUKpZcH4PfV9P18= -github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= -github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= -github.com/go-openapi/strfmt v0.26.2 h1:ysjheCh4i1rmFEo2LanhELDNucNzfWTZhUDKgWWPaFM= -github.com/go-openapi/strfmt v0.26.2/go.mod h1:fXh1e449cyUn2NYuz+wb3wARBUdMl7qPEZwX00nqivY= +github.com/go-openapi/runtime v0.32.3 h1:J7Ycy5DJmhhP1By3NifhRUjnkXTrk21qbeqSULjwX8U= +github.com/go-openapi/runtime v0.32.3/go.mod h1:/WTQi0fa5DiGnnCXQKsTkSm15OzJp8Uz3H2t+67TBr4= +github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog= +github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs= +github.com/go-openapi/spec v0.22.5 h1:KhO7RBlKQfonUWX2WzQCoLIXVA6AcNqDGZ3a1Dutdlo= +github.com/go-openapi/spec v0.22.5/go.mod h1:vxpOtMya5TXtENXKE5bKqv5NjocVhyhxHrlZfvKnZ74= +github.com/go-openapi/strfmt v0.26.3 h1:rzmslHarJgBbf2qfGge+X3htclQfmXqBZMm0Too0HhU= +github.com/go-openapi/strfmt v0.26.3/go.mod h1:a5nsUw0oRpQzZeOwx8bi6cKbzFZslpbCKt1LEot+KnQ= github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= -github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= -github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= +github.com/go-openapi/swag/conv v0.26.1 h1:slr5FVkg9Wc3Y5zcwenD8Sd/PQ94b2I/QJI7N7KTBpg= +github.com/go-openapi/swag/conv v0.26.1/go.mod h1:mvQXgPptZk9GTrFgGwWvT4q+dN+zQej9JfmGwnipz1A= github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= @@ -352,22 +332,21 @@ github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKB github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= -github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= -github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= +github.com/go-openapi/swag/typeutils v0.26.1 h1:yg42FgMzRR6PVQ3M3qHz1s+Y6/P4HoJ3cBarXa3OVnU= +github.com/go-openapi/swag/typeutils v0.26.1/go.mod h1:VfnV+oUtSP2vCSCn2aJgnr8OevUYemyIzzS1VOzS10o= github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= -github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= -github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= -github.com/go-openapi/validate v0.25.2 h1:12NsfLAwGegqbGWr2CnvT65X/Q2USJipmJ9b7xDJZz0= -github.com/go-openapi/validate v0.25.2/go.mod h1:Pgl1LpPPGFnZ+ys4/hTlDiRYQdI1ocKypgE+8Q8BLfY= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= +github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= +github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.25.3 h1:4nzAIavcJ7WveHK2+V1UAkZK3kWcjzxZCzjfZAfavKs= +github.com/go-openapi/validate v0.25.3/go.mod h1:GemfuGMyYpIaBoKpX3z8sLywrmxpzWVOoJ7R0VeAVuk= github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -386,7 +365,6 @@ github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/gogo/googleapis v0.0.0-20180223154316-0cd9801be74a h1:dR8+Q0uO5S2ZBcs2IH6VBKYwSxPo2vYCYq0ot0mu7xA= github.com/gogo/googleapis v0.0.0-20180223154316-0cd9801be74a/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/status v1.1.1 h1:DuHXlSFHNKqTQ+/ACf5Vs6r4X/dH2EgIzR9Vr+H65kg= @@ -399,7 +377,6 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2V github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -407,7 +384,6 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -427,7 +403,6 @@ github.com/google/go-attestation v0.5.1/go.mod h1:KqGatdUhg5kPFkokyzSBDxwSCFyRgI github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -436,18 +411,18 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-configfs-tsm v0.3.3-0.20240919001351-b4b5b84fdcbc h1:SG12DWUUM5igxm+//YX5Yq4vhdoRnOG9HkCodkOn+YU= github.com/google/go-configfs-tsm v0.3.3-0.20240919001351-b4b5b84fdcbc/go.mod h1:EL1GTDFMb5PZQWDviGfZV9n87WeGTR/JUg13RfwkgRo= -github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM= -github.com/google/go-containerregistry v0.21.6/go.mod h1:U7MMSBIJynke2MVQrQk19NP9k/uQsGz/h0amIFSHMbo= -github.com/google/go-eventlog v0.0.3-0.20260305053119-5cd85087f9f9 h1:YDFEn5cuFcI2xNV3D8N4xpsV0y+D29s94cfyW1tGd4E= -github.com/google/go-eventlog v0.0.3-0.20260305053119-5cd85087f9f9/go.mod h1:7huE5P8w2NTObSwSJjboHmB7ioBNblkijdzoVa2skfQ= +github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= +github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= +github.com/google/go-eventlog v0.0.3-0.20260416001248-6807b85eecf0 h1:STyioPkz8nqMMIk3+YlyJ/WyEJZxho1YUZXu99uAbQ0= +github.com/google/go-eventlog v0.0.3-0.20260416001248-6807b85eecf0/go.mod h1:7huE5P8w2NTObSwSJjboHmB7ioBNblkijdzoVa2skfQ= github.com/google/go-sev-guest v0.14.0 h1:dCb4F3YrHTtrDX3cYIPTifEDz7XagZmXQioxRBW4wOo= github.com/google/go-sev-guest v0.14.0/go.mod h1:SK9vW+uyfuzYdVN0m8BShL3OQCtXZe/JPF7ZkpD3760= github.com/google/go-tdx-guest v0.3.2-0.20250814004405-ffb0869e6f4d h1:Ff8goEP/ue2/rZT5qyoRicuySCYDbAXEZS8Cf1fgsUo= github.com/google/go-tdx-guest v0.3.2-0.20250814004405-ffb0869e6f4d/go.mod h1:uHy3VaNXNXhl0fiPxKqTxieeouqQmW6A0EfLcaeCYBk= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/google/go-tpm-tools v0.4.8 h1:V4oIYyAD3BykOycwYQzO29WefDouQMTsYZqmG3HxOfM= -github.com/google/go-tpm-tools v0.4.8/go.mod h1:4DfiOtiS1KppJjwf1+tqtW4K3PrCJjAAqFKj/TYTJKg= +github.com/google/go-tpm-tools v0.4.9 h1:jZEhnE4WRFbomSssBH2gWaIViIHU1gjH1jz76+xC9bI= +github.com/google/go-tpm-tools v0.4.9/go.mod h1:Omb8zosA8qY9URn1gsrO2i4b6DFqGp29BqNx18V66c4= github.com/google/go-tspi v0.3.0 h1:ADtq8RKfP+jrTyIWIZDIYcKOMecRqNJFOew2IT0Inus= github.com/google/go-tspi v0.3.0/go.mod h1:xfMGI3G0PhxCdNVcYr1C4C+EizojDg/TXuX5by8CiHI= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -476,27 +451,24 @@ github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyE github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-metrics v0.5.4 h1:8mmPiIJkTPPEbAiV97IxdAGNdRdaWwVap1BU6elejKY= -github.com/hashicorp/go-metrics v0.5.4/go.mod h1:CG5yz4NZ/AI/aQt9Ucm/vdBnbh7fvmv4lxZ350i+QQI= +github.com/hashicorp/go-metrics v0.6.0 h1:+kjWqHRH2HxAocneVfB/BI6EeWUUHyPhyQZozMT8Ed4= +github.com/hashicorp/go-metrics v0.6.0/go.mod h1:0B52B5pZ7+qm5Zhzs8Fygr87isvmUgr0Zv9rmJ9qsnQ= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs= github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= -github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= @@ -513,8 +485,6 @@ github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A= @@ -532,12 +502,8 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= -github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= -github.com/imkira/go-observer v1.0.3 h1:l45TYAEeAB4L2xF6PR2gRLn2NE5tYhudh33MLmC7B80= -github.com/imkira/go-observer v1.0.3/go.mod h1:zLzElv2cGTHufQG17IEILJMPDg32TD85fFgKyFv00wU= -github.com/in-toto/attestation v1.1.2 h1:MBFn6lsMq6dptQZJBhalXTcWMb/aJy3V+GX3VYj/V1E= -github.com/in-toto/attestation v1.1.2/go.mod h1:gYFddHMZj3DiQ0b62ltNi1Vj5rC879bTmBbrv9CRHpM= +github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= +github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk= github.com/in-toto/in-toto-golang v0.11.0 h1:nfidMYBFx+E0lnmX5KUnN2Pdm8zdNKal1ayjJuzzRoA= github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMeKfVoWsWeFMDCMtw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -546,8 +512,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= -github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 h1:TMtDYDHKYY15rFihtRfck/bfFqNfvcabqvXAFQfAUpY= @@ -562,33 +528,18 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M= github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= -github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs= github.com/jmhodges/clock v1.2.0/go.mod h1:qKjhA7x7u/lQpPB1XAqX1b1lCI/w3/fNuYpI/ZjLynI= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -603,12 +554,12 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= -github.com/lestrrat-go/jwx/v3 v3.1.0 h1:AyyLtxc0QM75F75JroWgt1phwC7X+wOb3XKhH7XBZWw= -github.com/lestrrat-go/jwx/v3 v3.1.0/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= +github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw= +github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= -github.com/letsencrypt/boulder v0.20260223.0 h1:xdS2OnJNUasR6TgVIOpqqcvdkOu47+PQQMBk9ThuWBw= -github.com/letsencrypt/boulder v0.20260223.0/go.mod h1:r3aTSA7UZ7dbDfiGK+HLHJz0bWNbHk6YSPiXgzl23sA= +github.com/letsencrypt/boulder v0.20260309.0 h1:kZynrxK3QfqLGx6hhoz+Rfs3hgltJs1p9Mp+4+VwnY0= +github.com/letsencrypt/boulder v0.20260309.0/go.mod h1:yG8lj8pNPZ8taq3oNdTpfBS+eC74IaEuiewqzVpXiWE= github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= @@ -625,11 +576,10 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= -github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs= -github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ= +github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo= +github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ= +github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= github.com/mitchellh/cli v1.1.5 h1:OxRIeJXpAMztws/XHlN2vu6imG5Dpq+j61AzAX5fLng= github.com/mitchellh/cli v1.1.5/go.mod h1:v8+iFts2sPIKUV1ltktPXMCC8fumSKFItNcD2cLtRR4= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= @@ -644,28 +594,20 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= -github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= -github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= -github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= -github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= -github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= -github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 h1:Up6+btDp321ZG5/zdSLo48H9Iaq0UQGthrhWC6pCxzE= @@ -692,21 +634,17 @@ github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAl github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= -github.com/open-policy-agent/opa v1.16.2 h1:5gzbXeioG9TCguGOI9EKigeBP/1ZoD8VH/ca50ihGxI= -github.com/open-policy-agent/opa v1.16.2/go.mod h1:21uy+TcBM9muN9DvE9B6lcnovwTIBcE2Y9DRscar/uM= +github.com/open-policy-agent/opa v1.17.1 h1:wO0MOux/VCqY41aVAD6Toe1p3A7O7DlRZ1RHmYSpoS8= +github.com/open-policy-agent/opa v1.17.1/go.mod h1:lcuZYSlqQpXFzsA6EJCELmfR5+nNOpZYX+eo7xaIIlk= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= -github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM= github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= @@ -719,29 +657,12 @@ github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXq github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= @@ -763,36 +684,33 @@ github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= -github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY= -github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/sigstore/cosign/v3 v3.0.6 h1:k8XaUd9pmLknHBst/v0rUGHVdB4D9cfaBmWUaMAOocE= -github.com/sigstore/cosign/v3 v3.0.6/go.mod h1:ckLRkVecfUCYxL8isHODY9lwyKmDaRCPn00p6yFxHg0= +github.com/sigstore/cosign/v3 v3.1.1 h1:vYdLHfelistuiexNAM8rXAL5aru55g6p8KxzTOR8MO8= +github.com/sigstore/cosign/v3 v3.1.1/go.mod h1:xBOOidl1V2sGVbAiLtNzwiHnb4QvwQojCVC4x/O/QJI= github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= github.com/sigstore/rekor v1.5.2 h1:k6pX4o1zFAzAvDbXiVIp5IHj1b0wcDaxsbsbNpuRO8o= github.com/sigstore/rekor v1.5.2/go.mod h1:WkMnITBccOFauPkT6yte74tF5gC83pefKRGZvNOsbjI= -github.com/sigstore/rekor-tiles/v2 v2.2.1 h1:UmV1CBQ3SjxxPGpFmwDoOhoIwiKpM2Qm1pU5tPGmvNk= -github.com/sigstore/rekor-tiles/v2 v2.2.1/go.mod h1:z8n6l6oidpaLjjE6rJERuQqY9X38ulnHZCXyL+DEL7U= -github.com/sigstore/sigstore v1.10.6 h1:YWhMQfTrJSK80QB1pbxjYeAwGKx+5UwWPPAY9hrPPZg= -github.com/sigstore/sigstore v1.10.6/go.mod h1:k/mcVVXw3I87dYG/iCVTSW2xTrW7vPzxxGic4KqsqXs= -github.com/sigstore/sigstore-go v1.1.4 h1:wTTsgCHOfqiEzVyBYA6mDczGtBkN7cM8mPpjJj5QvMg= -github.com/sigstore/sigstore-go v1.1.4/go.mod h1:2U/mQOT9cjjxrtIUeKDVhL+sHBKsnWddn8URlswdBsg= -github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.6 h1:5OgLJUqWaw6+I0BM4W1eUEzCWzZT/hu1lXkw0oGyays= -github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.6/go.mod h1:h9eK9QyPqpFskF/ewFkRLtwh4/Q3FLc2/DXbym4IHN8= -github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.6 h1:U0OBU3vyU4wFPdQG65UelVmYKGEoP511JUBKwLdLOUc= -github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.6/go.mod h1:myZsg7wRiy/vf102g5uUAitYhtXCwepmAGxgHG1VHuE= -github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.6 h1:C4rrjnIWR7i9GoIpI2x9irNkUqVqAst2f3xIGMugDP8= -github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.6/go.mod h1:ejMD/17lMJ4HykQRPdj5NNr+OQYIEZto8HjDKghVMOA= -github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.6 h1:otSDRygk1TdzEiUBFyaGygT+qdwSnM9RT1i6BQMHmEY= -github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.6/go.mod h1:Ee/enmyxi/RFLVlajbnjgH2wOWQwlJ0wY8qZrk43hEw= -github.com/sigstore/timestamp-authority/v2 v2.0.6 h1:1Vh7/SdmLsVLG6Br6/bisd1SnlicfDm0MJYiA+D7Ppw= -github.com/sigstore/timestamp-authority/v2 v2.0.6/go.mod h1:Nk5ucGBDyH0tXAIMZ0prf6xn8qfTnbJhSq+CDabYcfc= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443 h1:/CO8F6m3Bo/f59bZo5dv1sTIfUnQqVnepIdDV24KoDw= +github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443/go.mod h1:w1h8wF8vq9lHjmtRdwJiEaoVxhP+WHIMpj4M39pkzp0= +github.com/sigstore/sigstore v1.10.8 h1:1Mgkxvkw4AXMfIP1DOjc6kw0GkUgA8pGVpveN/EfOq4= +github.com/sigstore/sigstore v1.10.8/go.mod h1:f9+B/4iaYimvUkySyb2mvc73n3RLqNn24grHZM/ET8M= +github.com/sigstore/sigstore-go v1.2.0 h1:8k8sGMVUUWwZ/KA+s4Q66yEPEzcC1xZ8UsTgI46J9Fc= +github.com/sigstore/sigstore-go v1.2.0/go.mod h1:I8BqVwAb/SaQJ5pBu5IDFY+ksq8O/1/kCag8XUgrsko= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8 h1:tofVQ+UWJgad/69I5zbqxdFCN5gpIn9tRQP7iBzIpBw= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8/go.mod h1:73AfJE8H6w5KGCFPBu4x/OG+i1Yxgmh0L/FtV7prd88= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8 h1:8Mt7J36GcUEmbiJaiFhz2tud5ZIgkfVVCe2H/WJCHmw= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8/go.mod h1:YiTpAsxoWXhF9KlLOVWCh7BckN5cYO8X01WufDq1ido= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8 h1:MxpAIMZVzn0Tpbarc9ax1I498oQBp7oYSMgoMSsOmKI= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8/go.mod h1:bnAUEkFNam6STvkVZhptVwWzWR5pS24CEtQ+lhxu7S0= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8 h1:1DGe4/clcdOnkz5MINEczWlmEvjUtZd+AjPPT/cBhQ8= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8/go.mod h1:6IDFhpgxtzqbnzrFkyegbj7RfWwKeRrb3/+xAD1Wp+Y= +github.com/sigstore/timestamp-authority/v2 v2.1.2 h1:7DDhnknLL4w8VwomyvW2W8qblOS9LDR8oihna+jc7Ls= +github.com/sigstore/timestamp-authority/v2 v2.1.2/go.mod h1:o6rAVZceFyejClIj/uStRNIemP16bVMZtbMmhk6pr0U= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/smallstep/pkcs7 v0.2.1 h1:6Kfzr/QizdIuB6LSv8y1LJdZ3aPSfTNhTLqAx9CTLfA= @@ -805,14 +723,13 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= -github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= +github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= github.com/spiffe/spire-api-sdk v1.2.5-0.20260428072036-00f73a61093a h1:x+oSCqSwCrpSUMHR0cuuRkFQAM6yAjVa0c5yAUOZjVg= github.com/spiffe/spire-api-sdk v1.2.5-0.20260428072036-00f73a61093a/go.mod h1:9hXJcMzatM1KwAtBDO3s6HccDCic++/5c2yOc5Iln8Y= -github.com/spiffe/spire-plugin-sdk v1.4.4-0.20251120194148-791bbd274dc7 h1:OAvr7TNirmBpXnAp82cTosuB+JAus5cyFCRqXHE0WHs= -github.com/spiffe/spire-plugin-sdk v1.4.4-0.20251120194148-791bbd274dc7/go.mod h1:QvrRDiBlXiJ7kNd176ZHsF5eklxxeTRgJSu2CXe0MKw= +github.com/spiffe/spire-plugin-sdk v1.4.4-0.20260617144146-5dcde407c4d1 h1:gN8nWPdRSxYIACrgdPBuVwhNe6ABzpvUVza7cP8Yy38= +github.com/spiffe/spire-plugin-sdk v1.4.4-0.20260617144146-5dcde407c4d1/go.mod h1:QvrRDiBlXiJ7kNd176ZHsF5eklxxeTRgJSu2CXe0MKw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -831,16 +748,14 @@ github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhg github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= -github.com/theupdateframework/go-tuf/v2 v2.4.1 h1:K6ewW064rKZCPkRo1W/CTbTtm/+IB4+coG1iNURAGCw= -github.com/theupdateframework/go-tuf/v2 v2.4.1/go.mod h1:Nex2enPVYDFCklrnbTzl3OVwD7fgIAj0J5++z/rvCj8= -github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 h1:N9UxlsOzu5mttdjhxkDLbzwtEecuXmlxZVo/ds7JKJI= -github.com/tink-crypto/tink-go-awskms/v2 v2.1.0/go.mod h1:PxSp9GlOkKL9rlybW804uspnHuO9nbD98V/fDX4uSis= +github.com/theupdateframework/go-tuf/v2 v2.4.2 h1:w7976/W8uTwlsegP5nRymlpjPgrwSh+AXUf85is6nJk= +github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks= github.com/tink-crypto/tink-go-awskms/v3 v3.0.0 h1:XSohRhCkXAVI0iaCnWB/GS05TEmpnKurQmzaY1jzt3Y= github.com/tink-crypto/tink-go-awskms/v3 v3.0.0/go.mod h1:+7MXsShLzVbSQ6dI0Pe4JuZM52jD1jQ1itAygd/MDsA= github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0= github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw= -github.com/tink-crypto/tink-go-hcvault/v2 v2.4.0 h1:j+S+WKBQ5ya26A5EM/uXoVe+a2IaPQN8KgBJZ22cJ+4= -github.com/tink-crypto/tink-go-hcvault/v2 v2.4.0/go.mod h1:OCKJIujnTzDq7f+73NhVs99oA2c1TR6nsOpuasYM6Yo= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0 h1:eXuNqgrcYelxU1MVikOJDP3wTS5lvihM4ntoAbAMfvs= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0/go.mod h1:3RhcxAqek6xUlRFmJifvU4CYLZN60KMQdIKqpZAZJG0= github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC60t388v4= github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= @@ -849,19 +764,18 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= -github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c h1:5a2XDQ2LiAUV+/RjckMyq9sXudfrPSuCY4FuPC1NyAw= -github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c/go.mod h1:g85IafeFJZLxlzZCDRu4JLpfS7HKzR+Hw9qRh3bVzDI= +github.com/transparency-dev/formats v0.1.1 h1:4bVHJc+KdBgpA1OJD1yjI+g0i5Z1graCppTMH8lWKJI= +github.com/transparency-dev/formats v0.1.1/go.mod h1:qtZ8goRuJ8FTBG9c9+Bj0rn2rUG7eG/AUTkr+Aw3jFw= github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= -github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= github.com/uber-go/tally/v4 v4.1.17 h1:C+U4BKtVDXTszuzU+WH8JVQvRVnaVKxzZrROFyDrvS8= github.com/uber-go/tally/v4 v4.1.17/go.mod h1:ZdpiHRGSa3z4NIAc1VlEH4SiknR885fOIF08xmS0gaU= github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= -github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc= -github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= +github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E= +github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= @@ -870,6 +784,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= @@ -892,28 +808,24 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= -go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA= go.step.sm/crypto v0.77.7/go.mod h1:OW/2sEHwTtDKq70PvSQ5B0JGy/CrLyDKOiVy3YvZMTQ= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= @@ -928,7 +840,6 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -941,8 +852,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -953,21 +864,16 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= @@ -980,18 +886,14 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -999,34 +901,25 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1045,8 +938,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1056,10 +949,9 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -1069,8 +961,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -1083,25 +975,26 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I= -google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/api v0.286.0 h1:TdTXMvzYKnWV1/lPbCdbXRqBrkDqjPto22H2xeZZ8LI= +google.golang.org/api v0.286.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ= google.golang.org/genproto v0.0.0-20180518175338-11a468237815/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= -google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d h1:N1Ec54vZnIPd7MnxRiYLW+oY4fDR4BOS/LrssdD9+ek= +google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:c2hJ1grtnH0xUiEKGDGkjGNTJ1Hy2LrblyKOHF0sqRM= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad h1:45WmJvIV6C2+O/jjLkPUH+F3aOj/1miDoU2DD0+NWbg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.12.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= @@ -1115,9 +1008,7 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= @@ -1127,10 +1018,8 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -1138,24 +1027,24 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY= -k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo= +k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY= +k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg= k8s.io/apiextensions-apiserver v0.36.0 h1:Wt7E8J+VBCbj4FjiBfDTK/neXDDjyJVJc7xfuOHImZ0= k8s.io/apiextensions-apiserver v0.36.0/go.mod h1:kGDjH0msuiIB3tgsYRV0kS9GqpMYMUsQ3GHv7TApyug= -k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA= -k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8= -k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0= -k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU= +k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ= +k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4= +k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI= +k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-aggregator v0.36.1 h1:IzNeRsJcTtgsiCyTgCR1pSwWCrXC1QZQWMTcBw18cFQ= -k8s.io/kube-aggregator v0.36.1/go.mod h1:ROrIm5irUhVUJsKVCgBAAcXpK5IiqpdCn0Ka7LYMGs4= +k8s.io/kube-aggregator v0.36.2 h1:zfeH9Fs16oDquNfBZef3M27dGG3QtJ9/TwkWfBlw1lo= +k8s.io/kube-aggregator v0.36.2/go.mod h1:UMrB5DfEhznFTf0bqYW2SV26GDy8HNaxoYakvKVWZ8M= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= -k8s.io/mount-utils v0.36.1 h1:NWDFsdv+jfqPfa/LisnbEn1QyPNYjMNkmfEORXhyvZA= -k8s.io/mount-utils v0.36.1/go.mod h1:+I47UOG6FiUGVSy7VanjU/mQXLShMo3M7xBpGLzCub8= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 8915079978..bd63a086bb 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -13,10 +13,13 @@ import ( "github.com/andres-erbsen/clock" "github.com/sirupsen/logrus" + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/go-spiffe/v2/svid/x509svid" "github.com/spiffe/go-spiffe/v2/workloadapi" admin_api "github.com/spiffe/spire/pkg/agent/api" node_attestor "github.com/spiffe/spire/pkg/agent/attestor/node" workload_attestor "github.com/spiffe/spire/pkg/agent/attestor/workload" + "github.com/spiffe/spire/pkg/agent/broker" "github.com/spiffe/spire/pkg/agent/catalog" "github.com/spiffe/spire/pkg/agent/endpoints" "github.com/spiffe/spire/pkg/agent/manager" @@ -79,11 +82,46 @@ func (a *Agent) Run(ctx context.Context) error { defer stopProfiling() } + var mgr manager.Manager metrics, err := telemetry.NewMetrics(&telemetry.MetricsConfig{ FileConfig: a.c.Telemetry, Logger: a.c.Log.WithField(telemetry.SubsystemName, telemetry.Telemetry), ServiceName: telemetry.SpireAgent, TrustDomain: a.c.TrustDomain.Name(), + TLSPolicy: a.c.TLSPolicy, + GetX509SVID: func() (*x509svid.SVID, error) { + if mgr == nil { + return nil, errors.New("agent manager is not initialized") + } + + credentials := mgr.GetCurrentCredentials() + if len(credentials.SVID) == 0 { + return nil, errors.New("no certificates found") + } + + id, err := x509svid.IDFromCert(credentials.SVID[0]) + if err != nil { + return nil, err + } + + return &x509svid.SVID{ + ID: id, + Certificates: credentials.SVID, + PrivateKey: credentials.Key, + }, nil + }, + GetX509BundleAuthorities: func(td spiffeid.TrustDomain) ([]*x509.Certificate, error) { + if mgr == nil { + return nil, errors.New("agent manager is not initialized") + } + + bundle := mgr.GetBundles()[td] + if bundle == nil { + return nil, fmt.Errorf("no bundle found for trust domain %q", td) + } + + return bundle.X509Authorities(), nil + }, }) if err != nil { return err @@ -108,8 +146,6 @@ func (a *Agent) Run(ctx context.Context) error { } taskRunner := util.NewTaskRunner(ctx, cancel) - taskRunner.StartTasks(metrics.ListenAndServe) - nodeAttestor := nodeattestor.JoinToken(a.c.Log, a.c.JoinToken) if a.c.JoinToken == "" { nodeAttestor = cat.GetNodeAttestor() @@ -180,7 +216,7 @@ func (a *Agent) Run(ctx context.Context) error { if x509util.IsUnknownAuthorityError(err) { if a.c.TrustBundleSources.IsBootstrap() { - a.c.Log.Info("Trust Bundle and Server dont agree.... bootstrapping again") + a.c.Log.Info("Trust Bundle and Server don't agree, bootstrapping again") } else if a.c.RebootstrapMode != RebootstrapNever { startTime, err := a.c.TrustBundleSources.GetStartTime() if err != nil { @@ -189,10 +225,10 @@ func (a *Agent) Run(ctx context.Context) error { seconds := time.Since(startTime) if seconds < a.c.RebootstrapDelay { a.c.Log.WithFields(logrus.Fields{ - "time left": a.c.RebootstrapDelay - seconds, - }).Info("Trust Bundle and Server dont agree.... Ignoring for now.") + "time_left": a.c.RebootstrapDelay - seconds, + }).Info("Trust Bundle and Server don't agree, ignoring for now") } else { - a.c.Log.Warn("Trust Bundle and Server dont agree.... rebootstrapping") + a.c.Log.Warn("Trust Bundle and Server don't agree, rebootstrapping") err = sto.StoreBundle(nil) if err != nil { return err @@ -226,7 +262,7 @@ func (a *Agent) Run(ctx context.Context) error { svidStoreCache := a.newSVIDStoreCache(metrics) - manager, err := a.newManager(ctx, sto, cat, metrics, as, svidStoreCache, nodeAttestor) + mgr, err = a.newManager(ctx, sto, cat, metrics, as, svidStoreCache, nodeAttestor) if err != nil { return err } @@ -238,24 +274,43 @@ func (a *Agent) Run(ctx context.Context) error { Metrics: metrics, }) - agentEndpoints := a.newEndpoints(metrics, manager, workloadAttestor) + agentEndpoints := a.newEndpoints(metrics, mgr, workloadAttestor) go func() { agentEndpoints.WaitForListening(readyForHealthChecks) a.started = true }() tasks := []func(context.Context) error{ - manager.Run, + metrics.ListenAndServe, + mgr.Run, storeService.Run, agentEndpoints.ListenAndServe, catalog.ReconfigureTask(a.c.Log.WithField(telemetry.SubsystemName, "reconfigurer"), cat), } if a.c.AdminBindAddress != nil { - adminEndpoints := a.newAdminEndpoints(metrics, manager, workloadAttestor, a.c.AuthorizedDelegates) + adminEndpoints := a.newAdminEndpoints(metrics, mgr, workloadAttestor, a.c.AuthorizedDelegates) tasks = append(tasks, adminEndpoints.ListenAndServe) } + if len(a.c.Broker.BindAddresses) != 0 { + brokerEndpoints, err := broker.New(&broker.Config{ + BindAddrs: a.c.Broker.BindAddresses, + Manager: mgr, + Log: a.c.Log, + Metrics: metrics, + Attestor: workloadAttestor, + Brokers: a.c.Broker.Brokers, + SVIDSource: liveAgentSVIDSource{m: mgr}, + BundleSource: mgr.GetX509Bundle(), + TLSPolicy: a.c.TLSPolicy, + }) + if err != nil { + return fmt.Errorf("failed to create broker endpoints: %w", err) + } + tasks = append(tasks, brokerEndpoints.ListenAndServe) + } + if a.c.LogReopener != nil { tasks = append(tasks, a.c.LogReopener) } @@ -391,10 +446,10 @@ func (a *Agent) newManager(ctx context.Context, sto storage.Storage, cat catalog seconds := time.Since(startTime) if seconds < a.c.RebootstrapDelay { a.c.Log.WithFields(logrus.Fields{ - "time left": a.c.RebootstrapDelay - seconds, - }).Info("Trust Bundle and Server dont agree.... Ignoring for now.") + "time_left": a.c.RebootstrapDelay - seconds, + }).Info("Trust Bundle and Server don't agree, ignoring for now") } else { - a.c.Log.Info("Trust Bundle and Server dont agree.... rebootstrapping") + a.c.Log.Info("Trust Bundle and Server don't agree, rebootstrapping") err = a.c.TrustBundleSources.SetForceRebootstrap() if err != nil { return nil, err @@ -461,7 +516,9 @@ func (a *Agent) newEndpoints(metrics telemetry.Metrics, mgr manager.Manager, att DisableSPIFFECertValidation: a.c.DisableSPIFFECertValidation, AllowUnauthenticatedVerifiers: a.c.AllowUnauthenticatedVerifiers, AllowedForeignJWTClaims: a.c.AllowedForeignJWTClaims, + LogSelectors: a.c.LogSelectors, TrustDomain: a.c.TrustDomain, + WorkloadAPIRateLimit: a.c.WorkloadAPIRateLimit, }) } @@ -470,6 +527,7 @@ func (a *Agent) newAdminEndpoints(metrics telemetry.Metrics, mgr manager.Manager BindAddr: a.c.AdminBindAddress, Manager: mgr, Log: a.c.Log, + RootLog: a.c.Log, Metrics: metrics, TrustDomain: a.c.TrustDomain, Uptime: uptime.Uptime, @@ -520,6 +578,27 @@ type agentHealthDetails struct { WorkloadAPIErr string `json:"make_new_x509_err,omitempty"` } +// liveAgentSVIDSource adapts the agent manager into an x509svid.Source that +// always reflects the manager's currently rotated agent SVID. Without this, +// passing the bootstrap AttestationResult directly would freeze the listener +// on the bootstrap cert until it expires. +type liveAgentSVIDSource struct { + m manager.Manager +} + +func (s liveAgentSVIDSource) GetX509SVID() (*x509svid.SVID, error) { + state := s.m.GetCurrentCredentials() + id, err := x509svid.IDFromCert(state.SVID[0]) + if err != nil { + return nil, err + } + return &x509svid.SVID{ + ID: id, + Certificates: state.SVID, + PrivateKey: state.Key, + }, nil +} + func errString(suppress bool, err error) string { if suppress { return "" diff --git a/pkg/agent/api/config.go b/pkg/agent/api/config.go index 128b5ccaba..185997113b 100644 --- a/pkg/agent/api/config.go +++ b/pkg/agent/api/config.go @@ -6,6 +6,7 @@ import ( "github.com/sirupsen/logrus" "github.com/spiffe/go-spiffe/v2/spiffeid" + loggerv1 "github.com/spiffe/spire/pkg/agent/api/logger/v1" attestor "github.com/spiffe/spire/pkg/agent/attestor/workload" "github.com/spiffe/spire/pkg/agent/manager" "github.com/spiffe/spire/pkg/common/peertracker" @@ -19,6 +20,9 @@ type Config struct { Log logrus.FieldLogger + // The root logger for the entire process + RootLog loggerv1.Logger + Metrics telemetry.Metrics // Agent trust domain diff --git a/pkg/agent/api/delegatedidentity/v1/service.go b/pkg/agent/api/delegatedidentity/v1/service.go index a92375e4e6..a1835cd85a 100644 --- a/pkg/agent/api/delegatedidentity/v1/service.go +++ b/pkg/agent/api/delegatedidentity/v1/service.go @@ -85,7 +85,7 @@ func (s *Service) isCallerAuthorized(ctx context.Context, log logrus.FieldLogger callerSelectors, err = s.peerAttestor.Attest(ctx) if err != nil { log.WithError(err).Error("Workload attestation failed") - return nil, status.Error(codes.Internal, "workload attestation failed") + return nil, workloadAttestationFailedError(ctx) } } @@ -114,6 +114,13 @@ func (s *Service) isCallerAuthorized(ctx context.Context, log logrus.FieldLogger return nil, status.Error(codes.PermissionDenied, "caller not configured as an authorized delegate") } +func workloadAttestationFailedError(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + return status.Error(codes.Unavailable, "workload attestation failed") +} + func (s *Service) constructValidSelectorsFromReq(ctx context.Context, log logrus.FieldLogger, reqPid int32, reqSelectors []*types.Selector) ([]*common.Selector, error) { // If you set // - both pid and selector args @@ -142,7 +149,8 @@ func (s *Service) constructValidSelectorsFromReq(ctx context.Context, log logrus // Delegate authorized, use PID the delegate gave us to try and attest on-behalf-of selectors, err = s.delegateWorkloadAttestor.Attest(ctx, int(reqPid)) if err != nil { - return nil, err + log.WithError(err).Error("Workload attestation failed") + return nil, workloadAttestationFailedError(ctx) } } @@ -228,11 +236,11 @@ func sendX509SVIDResponse(update *cache.WorkloadUpdate, stream delegatedidentity // log details on each SVID // a response has already been sent so nothing is // blocked on this logic - for i, svid := range resp.X509Svids { + for _, svid := range resp.X509Svids { // Ideally ID Proto parsing should succeed, but if it fails, // ignore the error and still log with empty spiffe_id. id, _ := idutil.IDProtoString(svid.X509Svid.Id) - ttl := time.Until(update.Identities[i].SVID[0].NotAfter) + ttl := time.Until(time.Unix(svid.X509Svid.ExpiresAt, 0)) log.WithFields(logrus.Fields{ telemetry.SPIFFEID: id, telemetry.TTL: ttl.Seconds(), diff --git a/pkg/agent/api/delegatedidentity/v1/service_test.go b/pkg/agent/api/delegatedidentity/v1/service_test.go index 1ec08795a5..60bd1f1dd2 100644 --- a/pkg/agent/api/delegatedidentity/v1/service_test.go +++ b/pkg/agent/api/delegatedidentity/v1/service_test.go @@ -36,6 +36,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/anypb" ) var ( @@ -85,7 +86,7 @@ func TestSubscribeToX509SVIDs(t *testing.T) { { testName: "attest error", attestErr: errors.New("ohno"), - expectCode: codes.Internal, + expectCode: codes.Unavailable, expectMsg: "workload attestation failed", }, { @@ -326,6 +327,51 @@ func TestSubscribeToX509SVIDs(t *testing.T) { }, expectMetrics: generateSubscribeToX509SVIDMetrics(), }, + { + testName: "admin and downstream identities excluded from X509 SVIDs response", + authSpiffeID: []string{"spiffe://example.org/one"}, + identities: []cache.Identity{ + identities[0], + }, + updates: []*cache.WorkloadUpdate{ + { + Identities: []cache.Identity{ + { + Entry: &common.RegistrationEntry{ + SpiffeId: id1.String(), + Admin: true, + }, + PrivateKey: x509SVID1.PrivateKey, + SVID: x509SVID1.Certificates, + }, + { + Entry: &common.RegistrationEntry{ + SpiffeId: id1.String(), + Downstream: true, + }, + PrivateKey: x509SVID1.PrivateKey, + SVID: x509SVID1.Certificates, + }, + identities[1], + }, + Bundle: bundle, + }, + }, + expectResp: &delegatedidentityv1.SubscribeToX509SVIDsResponse{ + X509Svids: []*delegatedidentityv1.X509SVIDWithKey{ + { + X509Svid: &types.X509SVID{ + Id: utilIDProtoFromString(t, x509SVID2.ID.String()), + CertChain: x509util.RawCertsFromCertificates(x509SVID2.Certificates), + ExpiresAt: x509SVID2.Certificates[0].NotAfter.Unix(), + Hint: "external", + }, + X509SvidKey: pkcs8FromSigner(t, x509SVID2.PrivateKey), + }, + }, + }, + expectMetrics: generateSubscribeToX509SVIDMetrics(), + }, } { t.Run(tt.testName, func(t *testing.T) { metrics := fakemetrics.New() @@ -378,7 +424,7 @@ func TestSubscribeToX509Bundles(t *testing.T) { { testName: "Attest error", attestErr: errors.New("ohno"), - expectCode: codes.Internal, + expectCode: codes.Unavailable, expectMsg: "workload attestation failed", }, { @@ -480,6 +526,7 @@ func TestFetchJWTSVIDs(t *testing.T) { expectCode codes.Code expectMsg string attestErr error + delegateErr error managerErr error expectResp *delegatedidentityv1.FetchJWTSVIDsResponse }{ @@ -492,7 +539,7 @@ func TestFetchJWTSVIDs(t *testing.T) { testName: "Attest error", attestErr: errors.New("ohno"), audience: []string{"AUDIENCE"}, - expectCode: codes.Internal, + expectCode: codes.Unavailable, expectMsg: "workload attestation failed", }, { @@ -574,6 +621,18 @@ func TestFetchJWTSVIDs(t *testing.T) { expectCode: codes.InvalidArgument, expectMsg: "could not parse provided selectors", }, + { + testName: "delegate workload attest error", + authSpiffeID: []string{"spiffe://example.org/one"}, + pid: 447, + audience: []string{"AUDIENCE"}, + identities: []cache.Identity{ + identities[0], + }, + delegateErr: errors.New("ohno"), + expectCode: codes.Unavailable, + expectMsg: "workload attestation failed", + }, { testName: "success with one identity", authSpiffeID: []string{"spiffe://example.org/one"}, @@ -742,6 +801,7 @@ func TestFetchJWTSVIDs(t *testing.T) { Identities: tt.identities, AuthSpiffeID: tt.authSpiffeID, AttestErr: tt.attestErr, + DelegateErr: tt.delegateErr, ManagerErr: tt.managerErr, JwtSVIDS: tt.jwtSVIDsResp, } @@ -786,7 +846,7 @@ func TestSubscribeToJWTBundles(t *testing.T) { { testName: "Attest error", attestErr: errors.New("ohno"), - expectCode: codes.Internal, + expectCode: codes.Unavailable, expectMsg: "workload attestation failed", }, { @@ -870,6 +930,7 @@ type testParams struct { JwtSVIDS map[spiffeid.ID]*client.JWTSVID AuthSpiffeID []string AttestErr error + DelegateErr error ManagerErr error Metrics *fakemetrics.FakeMetrics } @@ -900,7 +961,7 @@ func runTest(t *testing.T, params testParams, fn func(ctx context.Context, clien } service.delegateWorkloadAttestor = FakeWorkloadPIDAttestor{ - err: params.AttestErr, + err: params.DelegateErr, } unaryInterceptor, streamInterceptor := middleware.Interceptors(middleware.WithLogger(log)) @@ -940,6 +1001,10 @@ func (fa FakeWorkloadPIDAttestor) Attest(_ context.Context, _ int) ([]*common.Se return fa.selectors, fa.err } +func (fa FakeWorkloadPIDAttestor) AttestReference(_ context.Context, _ *anypb.Any) ([]*common.Selector, error) { + return fa.selectors, fa.err +} + type FakeManager struct { manager.Manager diff --git a/pkg/agent/api/endpoints.go b/pkg/agent/api/endpoints.go index 202d663b30..28d74e018c 100644 --- a/pkg/agent/api/endpoints.go +++ b/pkg/agent/api/endpoints.go @@ -7,6 +7,7 @@ import ( "github.com/sirupsen/logrus" debugv1 "github.com/spiffe/spire/pkg/agent/api/debug/v1" delegatedidentityv1 "github.com/spiffe/spire/pkg/agent/api/delegatedidentity/v1" + loggerv1 "github.com/spiffe/spire/pkg/agent/api/logger/v1" "github.com/spiffe/spire/pkg/agent/endpoints" "github.com/spiffe/spire/pkg/common/api/middleware" "github.com/spiffe/spire/pkg/common/peertracker" @@ -37,6 +38,7 @@ func (e *Endpoints) ListenAndServe(ctx context.Context) error { e.registerDebugAPI(server) e.registerDelegatedIdentityAPI(server) + e.registerLoggerAPI(server) l, err := e.createListener() if err != nil { @@ -77,6 +79,14 @@ func (e *Endpoints) registerDebugAPI(server *grpc.Server) { debugv1.RegisterService(server, service) } +func (e *Endpoints) registerLoggerAPI(server *grpc.Server) { + service := loggerv1.New(loggerv1.Config{ + Log: e.c.RootLog, + }) + + loggerv1.RegisterService(server, service) +} + func (e *Endpoints) registerDelegatedIdentityAPI(server *grpc.Server) { service := delegatedidentityv1.New(delegatedidentityv1.Config{ Manager: e.c.Manager, diff --git a/pkg/agent/api/health/v1/service.go b/pkg/agent/api/health/v1/service.go index e06e5cd343..a344be5999 100644 --- a/pkg/agent/api/health/v1/service.go +++ b/pkg/agent/api/health/v1/service.go @@ -6,10 +6,10 @@ import ( "github.com/sirupsen/logrus" "github.com/spiffe/go-spiffe/v2/workloadapi" + "github.com/spiffe/spire/pkg/agent/api/rpccontext" + "github.com/spiffe/spire/pkg/common/api" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/common/util" - "github.com/spiffe/spire/pkg/server/api" - "github.com/spiffe/spire/pkg/server/api/rpccontext" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/health/grpc_health_v1" diff --git a/pkg/agent/api/logger/v1/service.go b/pkg/agent/api/logger/v1/service.go new file mode 100644 index 0000000000..66dc2e6953 --- /dev/null +++ b/pkg/agent/api/logger/v1/service.go @@ -0,0 +1,92 @@ +package logger + +import ( + "context" + + "github.com/sirupsen/logrus" + loggerv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/agent/logger/v1" + apitype "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + "github.com/spiffe/spire/pkg/agent/api/rpccontext" + "github.com/spiffe/spire/pkg/common/api" + commonlogger "github.com/spiffe/spire/pkg/common/api/logger" + "github.com/spiffe/spire/pkg/common/telemetry" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" +) + +type Logger interface { + logrus.FieldLogger + + GetLevel() logrus.Level + SetLevel(level logrus.Level) +} + +func RegisterService(s grpc.ServiceRegistrar, service *Service) { + loggerv1.RegisterLoggerServer(s, service) +} + +type Config struct { + Log Logger +} + +type Service struct { + loggerv1.UnsafeLoggerServer + + log Logger + launchLevel logrus.Level +} + +func New(c Config) *Service { + launchLogLevel := c.Log.GetLevel() + c.Log.WithFields(logrus.Fields{ + telemetry.LaunchLogLevel: launchLogLevel.String(), + }).Info("Logger service configured") + + return &Service{ + log: c.Log, + launchLevel: launchLogLevel, + } +} + +func (s *Service) GetLogger(ctx context.Context, _ *loggerv1.GetLoggerRequest) (*apitype.Logger, error) { + log := rpccontext.Logger(ctx) + log.Info("GetLogger Called") + + return s.createAPILogger(), nil +} + +func (s *Service) SetLogLevel(ctx context.Context, req *loggerv1.SetLogLevelRequest) (*apitype.Logger, error) { + log := rpccontext.Logger(ctx) + + if req.NewLevel == apitype.LogLevel_UNSPECIFIED { + return nil, api.MakeErr(log, codes.InvalidArgument, "newLevel value cannot be LogLevel_UNSPECIFIED", nil) + } + + newLogLevel, ok := commonlogger.LogrusLevel[req.NewLevel] + if !ok { + return nil, api.MakeErr(log, codes.InvalidArgument, "unsupported log level", nil) + } + + log.WithFields(logrus.Fields{ + telemetry.NewLogLevel: newLogLevel.String(), + }).Info("SetLogLevel Called") + s.log.SetLevel(newLogLevel) + + return s.createAPILogger(), nil +} + +func (s *Service) ResetLogLevel(ctx context.Context, _ *loggerv1.ResetLogLevelRequest) (*apitype.Logger, error) { + log := rpccontext.Logger(ctx) + log.WithField(telemetry.LaunchLogLevel, s.launchLevel.String()).Info("ResetLogLevel Called") + + s.log.SetLevel(s.launchLevel) + + return s.createAPILogger(), nil +} + +func (s *Service) createAPILogger() *apitype.Logger { + return &apitype.Logger{ + CurrentLevel: commonlogger.APILevel[s.log.GetLevel()], + LaunchLevel: commonlogger.APILevel[s.launchLevel], + } +} diff --git a/pkg/agent/api/logger/v1/service_test.go b/pkg/agent/api/logger/v1/service_test.go new file mode 100644 index 0000000000..cbaa04bd33 --- /dev/null +++ b/pkg/agent/api/logger/v1/service_test.go @@ -0,0 +1,492 @@ +package logger_test + +import ( + "context" + "testing" + + "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" + loggerv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/agent/logger/v1" + apitype "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + "github.com/spiffe/spire/pkg/agent/api/logger/v1" + "github.com/spiffe/spire/pkg/agent/api/rpccontext" + "github.com/spiffe/spire/pkg/common/telemetry" + "github.com/spiffe/spire/test/grpctest" + "github.com/spiffe/spire/test/spiretest" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" +) + +func TestGetLogger(t *testing.T) { + for _, tt := range []struct { + name string + launchLevel logrus.Level + + expectedResponse *apitype.Logger + expectedLogs []spiretest.LogEntry + }{ + { + name: "test GetLogger on initialized to PANIC", + launchLevel: logrus.PanicLevel, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_PANIC, + LaunchLevel: apitype.LogLevel_PANIC, + }, + expectedLogs: nil, + }, + { + name: "test GetLogger on initialized to FATAL", + launchLevel: logrus.FatalLevel, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_FATAL, + LaunchLevel: apitype.LogLevel_FATAL, + }, + expectedLogs: nil, + }, + { + name: "test GetLogger on initialized to ERROR", + launchLevel: logrus.ErrorLevel, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_ERROR, + LaunchLevel: apitype.LogLevel_ERROR, + }, + expectedLogs: nil, + }, + { + name: "test GetLogger on initialized to WARN", + launchLevel: logrus.WarnLevel, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_WARN, + LaunchLevel: apitype.LogLevel_WARN, + }, + expectedLogs: nil, + }, + { + name: "test GetLogger on initialized to INFO", + launchLevel: logrus.InfoLevel, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_INFO, + LaunchLevel: apitype.LogLevel_INFO, + }, + expectedLogs: []spiretest.LogEntry{ + { + Level: logrus.InfoLevel, + Message: "GetLogger Called", + }, + }, + }, + { + name: "test GetLogger on initialized to DEBUG", + launchLevel: logrus.DebugLevel, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_DEBUG, + LaunchLevel: apitype.LogLevel_DEBUG, + }, + expectedLogs: []spiretest.LogEntry{ + { + Level: logrus.InfoLevel, + Message: "GetLogger Called", + }, + }, + }, + { + name: "test GetLogger on initialized to TRACE", + launchLevel: logrus.TraceLevel, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_TRACE, + LaunchLevel: apitype.LogLevel_TRACE, + }, + expectedLogs: []spiretest.LogEntry{ + { + Level: logrus.InfoLevel, + Message: "GetLogger Called", + }, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + test := setupServiceTest(t, tt.launchLevel) + defer test.Cleanup() + + resp, err := test.client.GetLogger(context.Background(), &loggerv1.GetLoggerRequest{}) + require.NoError(t, err) + spiretest.AssertLogs(t, test.logHook.AllEntries(), tt.expectedLogs) + spiretest.RequireProtoEqual(t, resp, tt.expectedResponse) + }) + } +} + +func TestSetLoggerThenGetLogger(t *testing.T) { + for _, tt := range []struct { + name string + launchLevel logrus.Level + setLogLevelRequest *loggerv1.SetLogLevelRequest + + expectedErr error + expectedResponse *apitype.Logger + expectedLogs []spiretest.LogEntry + }{ + { + name: "test SetLogger to FATAL on initialized to PANIC", + launchLevel: logrus.PanicLevel, + setLogLevelRequest: &loggerv1.SetLogLevelRequest{ + NewLevel: apitype.LogLevel_FATAL, + }, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_FATAL, + LaunchLevel: apitype.LogLevel_PANIC, + }, + }, + { + name: "test SetLogger to INFO on initialized to PANIC", + launchLevel: logrus.PanicLevel, + setLogLevelRequest: &loggerv1.SetLogLevelRequest{ + NewLevel: apitype.LogLevel_INFO, + }, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_INFO, + LaunchLevel: apitype.LogLevel_PANIC, + }, + expectedLogs: nil, + }, + { + name: "test SetLogger to PANIC on initialized to INFO", + launchLevel: logrus.InfoLevel, + setLogLevelRequest: &loggerv1.SetLogLevelRequest{ + NewLevel: apitype.LogLevel_PANIC, + }, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_PANIC, + LaunchLevel: apitype.LogLevel_INFO, + }, + expectedLogs: []spiretest.LogEntry{ + { + Level: logrus.InfoLevel, + Message: "SetLogLevel Called", + Data: logrus.Fields{ + telemetry.NewLogLevel: "panic", + }, + }, + }, + }, + { + name: "test SetLogger to INFO on initialized to INFO", + launchLevel: logrus.InfoLevel, + setLogLevelRequest: &loggerv1.SetLogLevelRequest{ + NewLevel: apitype.LogLevel_INFO, + }, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_INFO, + LaunchLevel: apitype.LogLevel_INFO, + }, + expectedLogs: []spiretest.LogEntry{ + { + Level: logrus.InfoLevel, + Message: "SetLogLevel Called", + Data: logrus.Fields{ + telemetry.NewLogLevel: "info", + }, + }, + }, + }, + { + name: "test SetLogger to DEBUG on initialized to INFO", + launchLevel: logrus.InfoLevel, + setLogLevelRequest: &loggerv1.SetLogLevelRequest{ + NewLevel: apitype.LogLevel_DEBUG, + }, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_DEBUG, + LaunchLevel: apitype.LogLevel_INFO, + }, + expectedLogs: []spiretest.LogEntry{ + { + Level: logrus.InfoLevel, + Message: "SetLogLevel Called", + Data: logrus.Fields{ + telemetry.NewLogLevel: "debug", + }, + }, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + test := setupServiceTest(t, tt.launchLevel) + defer test.Cleanup() + + resp, err := test.client.SetLogLevel(context.Background(), tt.setLogLevelRequest) + require.NoError(t, err) + spiretest.RequireProtoEqual(t, resp, tt.expectedResponse) + spiretest.AssertLogs(t, test.logHook.AllEntries(), tt.expectedLogs) + + getResp, err := test.client.GetLogger(context.Background(), &loggerv1.GetLoggerRequest{}) + require.Equal(t, err, tt.expectedErr) + spiretest.RequireProtoEqual(t, getResp, tt.expectedResponse) + }) + } +} + +func TestResetLogger(t *testing.T) { + for _, tt := range []struct { + name string + launchLevel logrus.Level + setLogLevelRequest *loggerv1.SetLogLevelRequest + + expectedResponse *apitype.Logger + expectedLogs []spiretest.LogEntry + }{ + { + name: "test PANIC Logger set to FATAL then RESET", + launchLevel: logrus.PanicLevel, + setLogLevelRequest: &loggerv1.SetLogLevelRequest{ + NewLevel: apitype.LogLevel_FATAL, + }, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_PANIC, + LaunchLevel: apitype.LogLevel_PANIC, + }, + }, + { + name: "test PANIC Logger set to INFO then RESET", + launchLevel: logrus.PanicLevel, + setLogLevelRequest: &loggerv1.SetLogLevelRequest{ + NewLevel: apitype.LogLevel_INFO, + }, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_PANIC, + LaunchLevel: apitype.LogLevel_PANIC, + }, + expectedLogs: []spiretest.LogEntry{ + { + Level: logrus.InfoLevel, + Message: "ResetLogLevel Called", + Data: logrus.Fields{ + telemetry.LaunchLogLevel: "panic", + }, + }, + }, + }, + { + name: "test INFO Logger set to PANIC and then RESET", + launchLevel: logrus.InfoLevel, + setLogLevelRequest: &loggerv1.SetLogLevelRequest{ + NewLevel: apitype.LogLevel_PANIC, + }, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_INFO, + LaunchLevel: apitype.LogLevel_INFO, + }, + // ResetLogLevel INFO log is suppressed while logger is at PANIC level + expectedLogs: nil, + }, + { + name: "test INFO Logger set to INFO and then RESET", + launchLevel: logrus.InfoLevel, + setLogLevelRequest: &loggerv1.SetLogLevelRequest{ + NewLevel: apitype.LogLevel_INFO, + }, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_INFO, + LaunchLevel: apitype.LogLevel_INFO, + }, + expectedLogs: []spiretest.LogEntry{ + { + Level: logrus.InfoLevel, + Message: "ResetLogLevel Called", + Data: logrus.Fields{ + telemetry.LaunchLogLevel: "info", + }, + }, + }, + }, + { + name: "test INFO Logger set to DEBUG and then RESET", + launchLevel: logrus.InfoLevel, + setLogLevelRequest: &loggerv1.SetLogLevelRequest{ + NewLevel: apitype.LogLevel_DEBUG, + }, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_INFO, + LaunchLevel: apitype.LogLevel_INFO, + }, + expectedLogs: []spiretest.LogEntry{ + { + Level: logrus.InfoLevel, + Message: "ResetLogLevel Called", + Data: logrus.Fields{ + telemetry.LaunchLogLevel: "info", + }, + }, + }, + }, + { + name: "test TRACE Logger set to PANIC and then RESET", + launchLevel: logrus.TraceLevel, + setLogLevelRequest: &loggerv1.SetLogLevelRequest{ + NewLevel: apitype.LogLevel_PANIC, + }, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_TRACE, + LaunchLevel: apitype.LogLevel_TRACE, + }, + // ResetLogLevel INFO log is suppressed while logger is at PANIC level + expectedLogs: nil, + }, + { + name: "test TRACE Logger set to INFO and then RESET", + launchLevel: logrus.TraceLevel, + setLogLevelRequest: &loggerv1.SetLogLevelRequest{ + NewLevel: apitype.LogLevel_INFO, + }, + + expectedResponse: &apitype.Logger{ + CurrentLevel: apitype.LogLevel_TRACE, + LaunchLevel: apitype.LogLevel_TRACE, + }, + expectedLogs: []spiretest.LogEntry{ + { + Level: logrus.InfoLevel, + Message: "ResetLogLevel Called", + Data: logrus.Fields{ + telemetry.LaunchLogLevel: "trace", + }, + }, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + test := setupServiceTest(t, tt.launchLevel) + defer test.Cleanup() + + _, err := test.client.SetLogLevel(context.Background(), tt.setLogLevelRequest) + require.NoError(t, err) + test.logHook.Reset() + + resp, err := test.client.ResetLogLevel(context.Background(), &loggerv1.ResetLogLevelRequest{}) + require.NoError(t, err) + + spiretest.RequireProtoEqual(t, resp, tt.expectedResponse) + spiretest.AssertLogs(t, test.logHook.AllEntries(), tt.expectedLogs) + + getResp, err := test.client.GetLogger(context.Background(), &loggerv1.GetLoggerRequest{}) + require.NoError(t, err) + spiretest.AssertProtoEqual(t, tt.expectedResponse, getResp) + }) + } +} + +func TestUnsetSetLogLevelRequest(t *testing.T) { + for _, tt := range []struct { + name string + launchLevel logrus.Level + setLogLevelRequest *loggerv1.SetLogLevelRequest + + code codes.Code + expectedErr string + expectedResponse *apitype.Logger + expectedLogs []spiretest.LogEntry + }{ + { + name: "logger not set without a log level", + launchLevel: logrus.DebugLevel, + setLogLevelRequest: &loggerv1.SetLogLevelRequest{}, + + code: codes.InvalidArgument, + expectedErr: "newLevel value cannot be LogLevel_UNSPECIFIED", + expectedResponse: nil, + expectedLogs: []spiretest.LogEntry{ + { + Level: logrus.ErrorLevel, + Message: "Invalid argument: newLevel value cannot be LogLevel_UNSPECIFIED", + }, + }, + }, + { + name: "logger not set to UNSPECIFIED", + launchLevel: logrus.DebugLevel, + setLogLevelRequest: &loggerv1.SetLogLevelRequest{ + NewLevel: apitype.LogLevel_UNSPECIFIED, + }, + + code: codes.InvalidArgument, + expectedErr: "newLevel value cannot be LogLevel_UNSPECIFIED", + expectedResponse: nil, + expectedLogs: []spiretest.LogEntry{ + { + Level: logrus.ErrorLevel, + Message: "Invalid argument: newLevel value cannot be LogLevel_UNSPECIFIED", + }, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + test := setupServiceTest(t, tt.launchLevel) + defer test.Cleanup() + + resp, err := test.client.SetLogLevel(context.Background(), tt.setLogLevelRequest) + spiretest.RequireGRPCStatusContains(t, err, tt.code, tt.expectedErr) + require.Nil(t, resp) + + spiretest.RequireProtoEqual(t, resp, tt.expectedResponse) + spiretest.AssertLogs(t, test.logHook.AllEntries(), tt.expectedLogs) + }) + } +} + +type serviceTest struct { + client loggerv1.LoggerClient + done func() + + logHook *test.Hook +} + +func (s *serviceTest) Cleanup() { + s.done() +} + +func setupServiceTest(t *testing.T, launchLevel logrus.Level) *serviceTest { + log, logHook := test.NewNullLogger() + log.SetLevel(launchLevel) + service := logger.New(logger.Config{ + Log: log, + }) + + registerFn := func(s grpc.ServiceRegistrar) { + logger.RegisterService(s, service) + } + overrideContext := func(ctx context.Context) context.Context { + ctx = rpccontext.WithLogger(ctx, log) + return ctx + } + server := grpctest.StartServer(t, registerFn, + grpctest.OverrideContext(overrideContext)) + conn := server.NewGRPCClient(t) + logHook.Reset() + + test := &serviceTest{ + done: server.Stop, + logHook: logHook, + client: loggerv1.NewLoggerClient(conn), + } + + return test +} diff --git a/pkg/agent/attestor/node/node.go b/pkg/agent/attestor/node/node.go index de8d52096d..65597fd608 100644 --- a/pkg/agent/attestor/node/node.go +++ b/pkg/agent/attestor/node/node.go @@ -36,13 +36,6 @@ const ( roundRobinServiceConfig = `{ "loadBalancingConfig": [ { "round_robin": {} } ] }` ) -type AttestationResult struct { - SVID []*x509.Certificate - Key keymanager.Key - Bundle *spiffebundle.Bundle - Reattestable bool -} - type Attestor interface { Attest(ctx context.Context) (*AttestationResult, error) } diff --git a/pkg/agent/attestor/node/result.go b/pkg/agent/attestor/node/result.go new file mode 100644 index 0000000000..27417ca1ff --- /dev/null +++ b/pkg/agent/attestor/node/result.go @@ -0,0 +1,39 @@ +package attestor + +import ( + "crypto/x509" + "fmt" + + "github.com/spiffe/go-spiffe/v2/bundle/spiffebundle" + "github.com/spiffe/go-spiffe/v2/bundle/x509bundle" + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/go-spiffe/v2/svid/x509svid" + "github.com/spiffe/spire/pkg/agent/plugin/keymanager" +) + +// Allow AttestationResult to be used as go-spiffe SVID and bundle sources. +var ( + _ x509svid.Source = (*AttestationResult)(nil) + _ x509bundle.Source = (*AttestationResult)(nil) +) + +type AttestationResult struct { + SVID []*x509.Certificate + Key keymanager.Key + Bundle *spiffebundle.Bundle + Reattestable bool +} + +func (ar *AttestationResult) GetX509SVID() (*x509svid.SVID, error) { + return &x509svid.SVID{ + Certificates: ar.SVID, + PrivateKey: ar.Key, + }, nil +} + +func (ar *AttestationResult) GetX509BundleForTrustDomain(trustDomain spiffeid.TrustDomain) (*x509bundle.Bundle, error) { + if ar.Bundle.TrustDomain() != trustDomain { + return nil, fmt.Errorf("bundle for trust domain %q not found", trustDomain) + } + return ar.Bundle.X509Bundle(), nil +} diff --git a/pkg/agent/attestor/workload/workload.go b/pkg/agent/attestor/workload/workload.go index b7cf7f463f..febc794fc2 100644 --- a/pkg/agent/attestor/workload/workload.go +++ b/pkg/agent/attestor/workload/workload.go @@ -2,8 +2,10 @@ package attestor import ( "context" + "errors" "fmt" "os" + "sync" "github.com/sirupsen/logrus" "github.com/spiffe/spire/pkg/agent/catalog" @@ -11,14 +13,20 @@ import ( "github.com/spiffe/spire/pkg/common/telemetry" telemetry_workload "github.com/spiffe/spire/pkg/common/telemetry/agent/workloadapi" "github.com/spiffe/spire/proto/spire/common" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/anypb" ) +var errReferenceUnsupported = errors.New("workload reference type unsupported by attestor") + type attestor struct { c *Config } type Attestor interface { Attest(ctx context.Context, pid int) ([]*common.Selector, error) + AttestReference(ctx context.Context, reference *anypb.Any) ([]*common.Selector, error) } func New(config *Config) Attestor { @@ -42,66 +50,129 @@ type Config struct { selectorHook func([]*common.Selector) } -// Attest invokes all workload attestor plugins against the provided PID. If an error -// is encountered, it is logged and selectors from the failing plugin are discarded. +// Attest invokes all workload attestor plugins against the provided PID. If some +// attestors fail, the errors are logged and selectors from the failing plugins +// are discarded. If all attestors fail, the combined error is returned. func (wla *attestor) Attest(ctx context.Context, pid int) ([]*common.Selector, error) { - counter := telemetry_workload.StartAttestationCall(wla.c.Metrics) - defer counter.Done(nil) - log := wla.c.Log.WithField(telemetry.PID, pid) - plugins := wla.c.Catalog.GetWorkloadAttestors() - sChan := make(chan []*common.Selector) - errChan := make(chan error) + selectors, err := wla.attest(ctx, func(a workloadattestor.WorkloadAttestor) ([]*common.Selector, error) { + var err error + counter := telemetry_workload.StartAttestorCall(wla.c.Metrics, a.Name()) + defer counter.Done(&err) + + selectors, err := a.Attest(ctx, pid) + if err != nil { + log.WithError(err).Errorf("workload attestor %q failed", a.Name()) + return nil, fmt.Errorf("workload attestor %q failed: %w", a.Name(), err) + } + return selectors, nil + }, nil, nil) + if err != nil { + return nil, err + } + // The agent health check currently exercises the Workload API. Since this + // can happen with some frequency, it has a tendency to fill up logs with + // hard-to-filter details if we're not careful (e.g. issue #1537). Only log + // if it is not the agent itself. + if pid != os.Getpid() { + log.WithField(telemetry.Selectors, selectors).Debug("PID attested to have selectors") + } + + return selectors, nil +} + +func (wla *attestor) AttestReference(ctx context.Context, reference *anypb.Any) ([]*common.Selector, error) { + log := wla.c.Log.WithField(telemetry.ReferenceType, reference.GetTypeUrl()) + selectors, err := wla.attest(ctx, func(a workloadattestor.WorkloadAttestor) ([]*common.Selector, error) { + var err error + counter := telemetry_workload.StartAttestorCall(wla.c.Metrics, a.Name()) + defer counter.Done(&err) + + selectors, err := a.AttestReference(ctx, reference) + if err != nil { + if status.Code(err) == codes.Unimplemented { + log.WithError(err).Debugf("workload attestor %q does not support reference attestation", a.Name()) + err = nil + return nil, errReferenceUnsupported + } + log.WithError(err).Errorf("workload attestor %q failed", a.Name()) + return nil, fmt.Errorf("workload attestor %q failed: %w", a.Name(), err) + } + return selectors, nil + }, errReferenceUnsupported, status.Error(codes.Unimplemented, "no workload attestor handled reference")) + if err != nil { + return nil, err + } + log.WithField(telemetry.Selectors, selectors).Debug("Reference attested to have selectors") + return selectors, nil +} + +func (wla *attestor) attest(ctx context.Context, attestFunc func(attestor workloadattestor.WorkloadAttestor) ([]*common.Selector, error), skippableErr error, allSkippedErr error) (_ []*common.Selector, retErr error) { + counter := telemetry_workload.StartAttestationCall(wla.c.Metrics) + defer counter.Done(&retErr) + + plugins := wla.c.Catalog.GetWorkloadAttestors() + // Buffered so plugin goroutines never block sending if the outer loop + // returns early (e.g., on ctx cancellation). Combined with the deferred + // wg.Wait() below, this guarantees plugin-level logs are flushed before + // we return to the caller. + sChan := make(chan []*common.Selector, len(plugins)) + errChan := make(chan error, len(plugins)) + + var wg sync.WaitGroup for _, p := range plugins { - go func(p workloadattestor.WorkloadAttestor) { - if selectors, err := wla.invokeAttestor(ctx, p, pid); err == nil { + wg.Go(func() { + if selectors, err := attestFunc(p); err == nil { sChan <- selectors } else { errChan <- err } - }(p) + }) } + defer wg.Wait() // Collect the results selectors := []*common.Selector{} + successes := 0 + skipped := 0 + var errs []error for range plugins { select { case s := <-sChan: + successes++ selectors = append(selectors, s...) wla.c.selectorHook(selectors) case err := <-errChan: if ctx.Err() != nil { - log.WithError(ctx.Err()).Error("Timed out collecting selectors for PID") + wla.c.Log.WithError(ctx.Err()).Error("Timed out collecting selectors") return nil, ctx.Err() } - log.WithError(err).Error("Failed to collect all selectors for PID") + if skippableErr != nil && errors.Is(err, skippableErr) { + skipped++ + continue + } + errs = append(errs, err) case <-ctx.Done(): - log.WithError(ctx.Err()).Error("Timed out collecting selectors for PID") + wla.c.Log.WithError(ctx.Err()).Error("Timed out collecting selectors") return nil, ctx.Err() } } - telemetry_workload.AddDiscoveredSelectorsSample(wla.c.Metrics, float32(len(selectors))) - // The agent health check currently exercises the Workload API. Since this - // can happen with some frequency, it has a tendency to fill up logs with - // hard-to-filter details if we're not careful (e.g. issue #1537). Only log - // if it is not the agent itself. - if pid != os.Getpid() { - log.WithField(telemetry.Selectors, selectors).Debug("PID attested to have selectors") + if len(plugins) > 0 && successes == 0 { + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + if skipped > 0 && allSkippedErr != nil { + return nil, allSkippedErr + } } - return selectors, nil -} - -// invokeAttestor invokes attestation against the supplied plugin. Should be called from a goroutine. -func (wla *attestor) invokeAttestor(ctx context.Context, a workloadattestor.WorkloadAttestor, pid int) (_ []*common.Selector, err error) { - counter := telemetry_workload.StartAttestorCall(wla.c.Metrics, a.Name()) - defer counter.Done(&err) - selectors, err := a.Attest(ctx, pid) - if err != nil { - return nil, fmt.Errorf("workload attestor %q failed: %w", a.Name(), err) + if len(errs) > 0 { + wla.c.Log.WithError(errors.Join(errs...)).Error("Failed to collect all selectors") } + + telemetry_workload.AddDiscoveredSelectorsSample(wla.c.Metrics, float32(len(selectors))) return selectors, nil } diff --git a/pkg/agent/attestor/workload/workload_test.go b/pkg/agent/attestor/workload/workload_test.go index 1c13ae3b9b..2b0ab15a21 100644 --- a/pkg/agent/attestor/workload/workload_test.go +++ b/pkg/agent/attestor/workload/workload_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "testing" "github.com/sirupsen/logrus" @@ -17,6 +18,9 @@ import ( "github.com/spiffe/spire/test/fakes/fakeworkloadattestor" "github.com/spiffe/spire/test/spiretest" "github.com/stretchr/testify/suite" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/anypb" ) var ( @@ -36,6 +40,7 @@ var ( 2: nil, 3: {"baz"}, 4: {"baz"}, + // 5: attestor2 cannot attest process 5 } ) @@ -71,28 +76,91 @@ func (s *WorkloadAttestorTestSuite) TestAttestWorkload() { // both attestors succeed but with no selectors selectors, err := s.attestor.Attest(ctx, 1) - s.Assert().Nil(err) + s.Nil(err) s.Empty(selectors) // attestor1 has selectors, but not attestor2 selectors, err = s.attestor.Attest(ctx, 2) - s.Assert().Nil(err) + s.Nil(err) spiretest.AssertProtoListEqual(s.T(), selectors1, selectors) // attestor2 has selectors, attestor1 fails selectors, err = s.attestor.Attest(ctx, 3) - s.Assert().Nil(err) + s.Nil(err) spiretest.AssertProtoListEqual(s.T(), selectors2, selectors) // both have selectors selectors, err = s.attestor.Attest(ctx, 4) - s.Assert().Nil(err) + s.Nil(err) util.SortSelectors(selectors) - combined := make([]*common.Selector, 0, len(selectors1)+len(selectors2)) - combined = append(combined, selectors1...) - combined = append(combined, selectors2...) + combined := slices.Concat(selectors1, selectors2) util.SortSelectors(combined) spiretest.AssertProtoListEqual(s.T(), combined, selectors) + + // neither attestor can attest the workload + selectors, err = s.attestor.Attest(ctx, 5) + spiretest.AssertErrorContains(s.T(), err, `workload attestor "fake1" failed`) + spiretest.AssertErrorContains(s.T(), err, `workloadattestor(fake1): cannot attest pid 5`) + spiretest.AssertErrorContains(s.T(), err, `workload attestor "fake2" failed`) + spiretest.AssertErrorContains(s.T(), err, `workloadattestor(fake2): cannot attest pid 5`) + s.Nil(selectors) +} + +func (s *WorkloadAttestorTestSuite) TestAttestLogsOnPartialFailure() { + s.catalog.SetWorkloadAttestors( + fakeworkloadattestor.New(s.T(), "fake1", attestor1Pids), + fakeworkloadattestor.New(s.T(), "fake2", attestor2Pids), + ) + + selectors, err := s.attestor.Attest(ctx, 3) + s.Nil(err) + spiretest.AssertProtoListEqual(s.T(), selectors2, selectors) + spiretest.AssertLogsAnyOrder(s.T(), s.loggerHook.AllEntries(), []spiretest.LogEntry{ + { + Level: logrus.ErrorLevel, + Message: `workload attestor "fake1" failed`, + Data: logrus.Fields{ + telemetry.PID: "3", + logrus.ErrorKey: "rpc error: code = Unknown desc = workloadattestor(fake1): cannot attest pid 3", + }, + }, + { + Level: logrus.ErrorLevel, + Message: "Failed to collect all selectors", + Data: logrus.Fields{ + logrus.ErrorKey: `workload attestor "fake1" failed: rpc error: code = Unknown desc = workloadattestor(fake1): cannot attest pid 3`, + }, + }, + }) +} + +func (s *WorkloadAttestorTestSuite) TestAttestReferenceSkipsUnsupportedAttestors() { + s.catalog.SetWorkloadAttestors( + &referenceWorkloadAttestor{name: "k8s", selectors: selectors1}, + &referenceWorkloadAttestor{name: "unix", err: status.Error(codes.Unimplemented, "AttestReference not implemented")}, + ) + + selectors, err := s.attestor.AttestReference(ctx, &anypb.Any{TypeUrl: "type.googleapis.com/example.Reference"}) + s.Require().NoError(err) + spiretest.AssertProtoListEqual(s.T(), selectors1, selectors) + for _, entry := range s.loggerHook.AllEntries() { + s.NotEqual(logrus.ErrorLevel, entry.Level) + } +} + +func (s *WorkloadAttestorTestSuite) TestAttestReferenceReturnsUnimplementedWhenNoAttestorHandlesReference() { + s.catalog.SetWorkloadAttestors( + &referenceWorkloadAttestor{name: "unix", err: status.Error(codes.Unimplemented, "AttestReference not implemented")}, + &referenceWorkloadAttestor{name: "docker", err: status.Error(codes.Unimplemented, "AttestReference not implemented")}, + ) + + selectors, err := s.attestor.AttestReference(ctx, &anypb.Any{TypeUrl: "type.googleapis.com/example.Reference"}) + s.Require().Error(err) + s.Equal(codes.Unimplemented, status.Code(err)) + s.Empty(selectors) + for _, entry := range s.loggerHook.AllEntries() { + s.NotEqual(logrus.ErrorLevel, entry.Level) + } } func (s *WorkloadAttestorTestSuite) TestAttestWorkloadMetrics() { @@ -106,7 +174,7 @@ func (s *WorkloadAttestorTestSuite) TestAttestWorkloadMetrics() { s.attestor.c.Metrics = metrics selectors, err := s.attestor.Attest(ctx, 2) - s.Assert().Nil(err) + s.Nil(err) // Create expected metrics expected := fakemetrics.New() @@ -124,7 +192,8 @@ func (s *WorkloadAttestorTestSuite) TestAttestWorkloadMetrics() { // No selectors expected selectors, err = s.attestor.Attest(ctx, 3) - s.Assert().Nil(err) + spiretest.AssertErrorContains(s.T(), err, `workload attestor "fake1" failed`) + spiretest.AssertErrorContains(s.T(), err, `workloadattestor(fake1): cannot attest pid 3`) s.Empty(selectors) // Create expected metrics with error key @@ -132,9 +201,8 @@ func (s *WorkloadAttestorTestSuite) TestAttestWorkloadMetrics() { err = errors.New("some error") attestorCounter = telemetry_workload.StartAttestorCall(expected, "fake1") attestorCounter.Done(&err) - telemetry_workload.AddDiscoveredSelectorsSample(expected, float32(0)) attestationCounter = telemetry_workload.StartAttestationCall(expected) - attestationCounter.Done(nil) + attestationCounter.Done(&err) s.Require().Equal(expected.AllMetrics(), metrics.AllMetrics()) } @@ -162,10 +230,10 @@ func (s *WorkloadAttestorTestSuite) TestAttestLogsOnContextCancellation() { ctx, cancel := context.WithCancel(context.Background()) var selectors []*common.Selector var attestErr error - go func(innerCtx context.Context, pid int) { - selectors, attestErr = s.attestor.Attest(innerCtx, pid) + go func() { + selectors, attestErr = s.attestor.Attest(ctx, pid) attestCh <- struct{}{} - }(ctx, pid) + }() // Wait for one of the plugins to return selectors <-selectorC @@ -178,14 +246,43 @@ func (s *WorkloadAttestorTestSuite) TestAttestLogsOnContextCancellation() { s.Assert().Nil(selectors) s.Assert().Error(attestErr) - spiretest.AssertLogs(s.T(), s.loggerHook.AllEntries(), []spiretest.LogEntry{ + spiretest.AssertLogsAnyOrder(s.T(), s.loggerHook.AllEntries(), []spiretest.LogEntry{ { Level: logrus.ErrorLevel, - Message: "Timed out collecting selectors for PID", + Message: "Timed out collecting selectors", Data: logrus.Fields{ - telemetry.PID: fmt.Sprint(pid), logrus.ErrorKey: context.Canceled.Error(), }, }, + { + Level: logrus.ErrorLevel, + Message: `workload attestor "faketimeoutattestor" failed`, + Data: logrus.Fields{ + telemetry.PID: fmt.Sprint(pid), + logrus.ErrorKey: "rpc error: code = Canceled desc = workloadattestor(faketimeoutattestor): context canceled", + }, + }, }) } + +type referenceWorkloadAttestor struct { + name string + selectors []*common.Selector + err error +} + +func (a *referenceWorkloadAttestor) Name() string { + return a.name +} + +func (a *referenceWorkloadAttestor) Type() string { + return "WorkloadAttestor" +} + +func (a *referenceWorkloadAttestor) Attest(context.Context, int) ([]*common.Selector, error) { + return nil, status.Error(codes.Unimplemented, "Attest not implemented") +} + +func (a *referenceWorkloadAttestor) AttestReference(context.Context, *anypb.Any) ([]*common.Selector, error) { + return a.selectors, a.err +} diff --git a/pkg/agent/broker/api/service.go b/pkg/agent/broker/api/service.go new file mode 100644 index 0000000000..f98e4c689a --- /dev/null +++ b/pkg/agent/broker/api/service.go @@ -0,0 +1,462 @@ +package api + +import ( + "context" + "crypto/x509" + "errors" + "fmt" + "net" + "time" + + "github.com/sirupsen/logrus" + "github.com/spiffe/go-spiffe/v2/exp/proto/spiffe/broker" + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire/pkg/agent/api/rpccontext" + workloadattestor "github.com/spiffe/spire/pkg/agent/attestor/workload" + "github.com/spiffe/spire/pkg/agent/broker/brokercontext" + "github.com/spiffe/spire/pkg/agent/client" + "github.com/spiffe/spire/pkg/agent/common/hintsfilter" + "github.com/spiffe/spire/pkg/agent/manager" + "github.com/spiffe/spire/pkg/agent/manager/cache" + "github.com/spiffe/spire/pkg/common/bundleutil" + "github.com/spiffe/spire/pkg/common/telemetry" + "github.com/spiffe/spire/pkg/common/telemetry/agent/adminapi" + "github.com/spiffe/spire/pkg/common/x509util" + "github.com/spiffe/spire/proto/spire/common" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/anypb" +) + +// RegisterService registers the SPIFFE Broker API service on the provided server. +func RegisterService(s *grpc.Server, service *Service) { + broker.RegisterAPIServer(s, service) +} + +type Config struct { + Log logrus.FieldLogger + Metrics telemetry.Metrics + Manager manager.Manager + Attestor workloadattestor.Attestor + + // AllowedReferenceTypesByCaller restricts which WorkloadReference type + // URLs each authenticated caller (broker SPIFFE ID) may use and whether + // each type is also allowed over TCP. A caller missing from the map has + // no restriction over UDS, but remains denied over TCP. + AllowedReferenceTypesByCaller map[spiffeid.ID]ReferenceTypePolicy +} + +func New(config Config) *Service { + return &Service{ + manager: config.Manager, + peerAttestor: config.Attestor, + metrics: config.Metrics, + allowedReferenceTypesByCaller: config.AllowedReferenceTypesByCaller, + } +} + +// ReferenceTypeAccess describes how a broker may use a WorkloadReference type. +type ReferenceTypeAccess struct { + AllowOverTCP bool +} + +// ReferenceTypePolicy is the per-broker WorkloadReference type policy. +type ReferenceTypePolicy struct { + AllowAny bool + AllowAnyOverTCP bool + Types map[string]ReferenceTypeAccess +} + +func (p ReferenceTypePolicy) AccessFor(typeURL string) (ReferenceTypeAccess, bool) { + if p.AllowAny { + return ReferenceTypeAccess{AllowOverTCP: p.AllowAnyOverTCP}, true + } + access, ok := p.Types[typeURL] + return access, ok +} + +// Service implements the SPIFFE Broker API server. +type Service struct { + broker.UnimplementedAPIServer + + manager manager.Manager + peerAttestor workloadattestor.Attestor + metrics telemetry.Metrics + allowedReferenceTypesByCaller map[spiffeid.ID]ReferenceTypePolicy +} + +// authorizeReferenceType applies the per-broker reference type policy. UDS +// requests only need the type to be allowed. TCP requests additionally need +// that same allowed type to opt in to TCP use. +func (s *Service) authorizeReferenceType(ctx context.Context, caller spiffeid.ID, ref *anypb.Any) error { + // Reject malformed requests before the allowlist gates so a missing or + // empty reference yields InvalidArgument rather than PermissionDenied. + if ref.GetTypeUrl() == "" { + return status.Error(codes.InvalidArgument, "workload reference must be provided") + } + + policy, ok := s.allowedReferenceTypesByCaller[caller] + if ok { + access, ok := policy.AccessFor(ref.GetTypeUrl()) + if !ok { + return status.Errorf(codes.PermissionDenied, "broker %q is not allowed to use reference type %q", caller, ref.GetTypeUrl()) + } + if isTCPCaller(ctx) && !access.AllowOverTCP { + return status.Errorf(codes.PermissionDenied, "reference type %q is not allowed over TCP for broker %q", ref.GetTypeUrl(), caller) + } + return nil + } + if isTCPCaller(ctx) { + return status.Errorf(codes.PermissionDenied, "reference type %q is not allowed over TCP for broker %q", ref.GetTypeUrl(), caller) + } + return nil +} + +// isTCPCaller reports whether the incoming gRPC connection is over TCP +// (as opposed to a Unix domain socket). +func isTCPCaller(ctx context.Context) bool { + p, ok := peer.FromContext(ctx) + if !ok { + return false + } + _, tcp := p.Addr.(*net.TCPAddr) + return tcp +} + +func (s *Service) getCallerContext(ctx context.Context) (spiffeid.ID, error) { + // The broker endpoint configures the gRPC server with plain + // credentials.NewTLS (to keep SessionTicketsDisabled and TLS policy + // customizations on the *tls.Config). That credentials wrapper exposes + // the peer SPIFFE ID via credentials.TLSInfo rather than go-spiffe's + // grpccredentials authInfo, so we extract it ourselves here. + p, ok := peer.FromContext(ctx) + if !ok || p.AuthInfo == nil { + return spiffeid.ID{}, status.Error(codes.Unauthenticated, "unable to determine caller identity") + } + tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo) + if !ok || tlsInfo.SPIFFEID == nil { + return spiffeid.ID{}, status.Error(codes.Unauthenticated, "unable to determine caller identity") + } + id, err := spiffeid.FromString(tlsInfo.SPIFFEID.String()) + if err != nil { + return spiffeid.ID{}, status.Errorf(codes.Unauthenticated, "invalid caller SPIFFE ID: %v", err) + } + return id, nil +} + +func (s *Service) SubscribeToX509SVID(req *broker.SubscribeToX509SVIDRequest, stream broker.API_SubscribeToX509SVIDServer) error { + latency := adminapi.StartFirstX509SVIDUpdateLatency(s.metrics) + ctx := stream.Context() + log := rpccontext.Logger(ctx) + var receivedFirstUpdate bool + + peer, err := s.getCallerContext(ctx) + if err != nil { + return err + } + log = log.WithField("broker_peer", peer.String()) + + if err := s.authorizeReferenceType(ctx, peer, req.GetReference().GetReference()); err != nil { + return err + } + + selectors, err := s.constructValidSelectorsFromReference(brokercontext.WithCallerID(ctx, peer), log, req.Reference) + if err != nil { + return err + } + + log.WithField(telemetry.Selectors, selectors).Debug("Subscribing to cache changes") + + subscriber, err := s.manager.SubscribeToCacheChanges(ctx, selectors) + if err != nil { + log.WithError(err).Error("Subscribe to cache changes failed") + return err + } + defer subscriber.Finish() + + for { + select { + case update := <-subscriber.Updates(): + update.Identities = hintsfilter.FilterIdentities(update.Identities, log) + if len(update.Identities) > 0 && !receivedFirstUpdate { + // emit latency metric for first update containing an SVID. + latency.Measure() + receivedFirstUpdate = true + } + + if err := sendX509SVIDResponse(update, stream, log); err != nil { + return err + } + case <-ctx.Done(): + return nil + } + } +} + +func (s *Service) SubscribeToX509Bundles(req *broker.SubscribeToX509BundlesRequest, stream broker.API_SubscribeToX509BundlesServer) error { + ctx := stream.Context() + log := rpccontext.Logger(ctx) + + peer, err := s.getCallerContext(ctx) + if err != nil { + return err + } + log = log.WithField("broker_peer", peer.String()) + + if err := s.authorizeReferenceType(ctx, peer, req.GetReference().GetReference()); err != nil { + return err + } + + // The bundle response is workload-independent, but per the SPIFFE Broker + // API spec the request still identifies a workload. Validate the reference + // resolves so a caller can't pull bundles for workloads it can't attest. + if _, err := s.constructValidSelectorsFromReference(brokercontext.WithCallerID(ctx, peer), log, req.Reference); err != nil { + return err + } + + subscriber := s.manager.SubscribeToBundleChanges() + + send := func(bundles map[spiffeid.TrustDomain]*cache.Bundle) error { + // Rebuild the map each tick — Next()/Value() return the full set of + // trust domains, so reusing the previous map would leave entries for + // trust domains that have since been removed. + caCerts := make(map[string][]byte, len(bundles)) + for td, bundle := range bundles { + caCerts[td.IDString()] = marshalBundle(bundle.X509Authorities()) + } + return stream.Send(&broker.SubscribeToX509BundlesResponse{Bundles: caCerts}) + } + + if err := send(subscriber.Value()); err != nil { + return err + } + + for { + select { + case <-subscriber.Changes(): + if err := send(subscriber.Next()); err != nil { + return err + } + case <-ctx.Done(): + return nil + } + } +} + +func (s *Service) FetchJWTSVID(ctx context.Context, req *broker.FetchJWTSVIDRequest) (*broker.FetchJWTSVIDResponse, error) { + log := rpccontext.Logger(ctx) + if len(req.Audience) == 0 { + log.Error("Missing required audience parameter") + return nil, status.Error(codes.InvalidArgument, "audience must be specified") + } + + peer, err := s.getCallerContext(ctx) + if err != nil { + return nil, err + } + log = log.WithField("broker_peer", peer.String()) + + if err := s.authorizeReferenceType(ctx, peer, req.GetReference().GetReference()); err != nil { + return nil, err + } + + selectors, err := s.constructValidSelectorsFromReference(brokercontext.WithCallerID(ctx, peer), log, req.Reference) + if err != nil { + return nil, err + } + + resp := new(broker.FetchJWTSVIDResponse) + entries := s.manager.MatchingRegistrationEntries(selectors) + entries = hintsfilter.FilterRegistrations(entries, log) + for _, entry := range entries { + spiffeID, err := spiffeid.FromString(entry.SpiffeId) + if err != nil { + log.WithField(telemetry.SPIFFEID, entry.SpiffeId).WithError(err).Error("Invalid requested SPIFFE ID") + return nil, status.Errorf(codes.InvalidArgument, "invalid requested SPIFFE ID: %v", err) + } + + loopLog := log.WithField(telemetry.SPIFFEID, spiffeID.String()) + + var svid *client.JWTSVID + svid, err = s.manager.FetchJWTSVID(ctx, entry, req.Audience) + if err != nil { + loopLog.WithError(err).Error("Could not fetch JWT-SVID") + return nil, status.Errorf(codes.Unavailable, "could not fetch JWT-SVID: %v", err) + } + resp.Svids = append(resp.Svids, &broker.JWTSVID{ + SpiffeId: spiffeID.String(), + Hint: entry.Hint, + Svid: svid.Token, + }) + + ttl := time.Until(svid.ExpiresAt) + loopLog.WithField(telemetry.TTL, ttl.Seconds()).Debug("Fetched JWT SVID") + } + + if len(resp.Svids) == 0 { + log.Error("No identity issued") + return nil, status.Error(codes.PermissionDenied, "no identity issued") + } + + return resp, nil +} + +func (s *Service) SubscribeToJWTBundles(req *broker.SubscribeToJWTBundlesRequest, stream broker.API_SubscribeToJWTBundlesServer) error { + ctx := stream.Context() + log := rpccontext.Logger(ctx) + + peer, err := s.getCallerContext(ctx) + if err != nil { + return err + } + log = log.WithField("broker_peer", peer.String()) + + if err := s.authorizeReferenceType(ctx, peer, req.GetReference().GetReference()); err != nil { + return err + } + + // The bundle response is workload-independent, but per the SPIFFE Broker + // API spec the request still identifies a workload. Validate the reference + // resolves so a caller can't pull bundles for workloads it can't attest. + if _, err := s.constructValidSelectorsFromReference(brokercontext.WithCallerID(ctx, peer), log, req.Reference); err != nil { + return err + } + + subscriber := s.manager.SubscribeToBundleChanges() + + send := func(bundles map[spiffeid.TrustDomain]*cache.Bundle) error { + // Rebuild the map each tick — Next()/Value() return the full set of + // trust domains, so reusing the previous map would leave entries for + // trust domains that have since been removed. + jwtbundles := make(map[string][]byte, len(bundles)) + for td, bundle := range bundles { + jwksBytes, err := bundleutil.Marshal(bundle, bundleutil.NoX509SVIDKeys(), bundleutil.StandardJWKS()) + if err != nil { + return err + } + jwtbundles[td.IDString()] = jwksBytes + } + return stream.Send(&broker.SubscribeToJWTBundlesResponse{Bundles: jwtbundles}) + } + + if err := send(subscriber.Value()); err != nil { + return err + } + + for { + select { + case <-subscriber.Changes(): + if err := send(subscriber.Next()); err != nil { + return err + } + case <-ctx.Done(): + return nil + } + } +} + +func (s *Service) constructValidSelectorsFromReference(ctx context.Context, log logrus.FieldLogger, ref *broker.WorkloadReference) ([]*common.Selector, error) { + if ref == nil { + log.Error("No workload reference provided") + return nil, status.Error(codes.InvalidArgument, "workload reference must be provided") + } + + selectors, err := s.peerAttestor.AttestReference(ctx, ref.Reference) + if err != nil { + log.WithError(err).Error("Workload attestation failed") + // Preserve the plugin's status (InvalidArgument, NotFound, + // PermissionDenied, etc.); only opaque errors are wrapped as + // Unauthenticated. + if _, ok := status.FromError(err); ok { + return nil, err + } + return nil, status.Errorf(codes.Unauthenticated, "workload attestation failed: %v", err) + } + + return selectors, nil +} + +func sendX509SVIDResponse(update *cache.WorkloadUpdate, stream broker.API_SubscribeToX509SVIDServer, log logrus.FieldLogger) (err error) { + resp, notAfters, err := composeX509SVIDBySelectors(update) + if err != nil { + log.WithError(err).Error("Could not serialize X.509 SVID response") + return status.Error(codes.Internal, "could not serialize response") + } + + if err := stream.Send(resp); err != nil { + log.WithError(err).Error("Failed to send X.509 SVID response") + return err + } + + log = log.WithField(telemetry.Count, len(resp.Svids)) + + // log details on each SVID + // a response has already been sent so nothing is + // blocked on this logic + for i, svid := range resp.Svids { + ttl := time.Until(notAfters[i]) + log.WithFields(logrus.Fields{ + telemetry.SPIFFEID: svid.SpiffeId, + telemetry.TTL: ttl.Seconds(), + }).Debug("Fetched X.509 SVID for broker") + } + + return nil +} + +func composeX509SVIDBySelectors(update *cache.WorkloadUpdate) (*broker.SubscribeToX509SVIDResponse, []time.Time, error) { + resp := new(broker.SubscribeToX509SVIDResponse) + resp.Svids = make([]*broker.X509SVID, 0, len(update.Identities)) + resp.FederatedBundles = make(map[string][]byte, len(update.FederatedBundles)) + notAfters := make([]time.Time, 0, len(update.Identities)) + + x509Bundle := marshalBundle(update.Bundle.X509Authorities()) + for _, identity := range update.Identities { + // Do not send admin nor downstream SVIDs to the caller + if identity.Entry.Admin || identity.Entry.Downstream { + continue + } + + // check if SVIDs exist for the identity + if len(identity.SVID) == 0 { + return nil, nil, errors.New("unable to get SVID from identity") + } + + id, err := spiffeid.FromString(identity.Entry.SpiffeId) + if err != nil { + return nil, nil, fmt.Errorf("error during SPIFFE ID parsing: %w", err) + } + + keyData, err := x509.MarshalPKCS8PrivateKey(identity.PrivateKey) + if err != nil { + return nil, nil, fmt.Errorf("marshal key for %v: %w", id, err) + } + + svid := &broker.X509SVID{ + SpiffeId: id.String(), + X509Svid: x509util.DERFromCertificates(identity.SVID), + Bundle: x509Bundle, + Hint: identity.Entry.Hint, + X509SvidKey: keyData, + } + resp.Svids = append(resp.Svids, svid) + notAfters = append(notAfters, identity.SVID[0].NotAfter) + } + + for td, bundle := range update.FederatedBundles { + resp.FederatedBundles[td.IDString()] = marshalBundle(bundle.X509Authorities()) + } + + return resp, notAfters, nil +} + +func marshalBundle(certs []*x509.Certificate) []byte { + bundle := []byte{} + for _, c := range certs { + bundle = append(bundle, c.Raw...) + } + return bundle +} diff --git a/pkg/agent/broker/api/service_test.go b/pkg/agent/broker/api/service_test.go new file mode 100644 index 0000000000..6e9dc0dfa0 --- /dev/null +++ b/pkg/agent/broker/api/service_test.go @@ -0,0 +1,175 @@ +package api + +import ( + "context" + "net" + "testing" + + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/anypb" +) + +const ( + k8sType = "type.googleapis.com/spiffe.broker.KubernetesObjectReference" + pidType = "type.googleapis.com/spiffe.broker.PIDReference" +) + +func udsContext() context.Context { + return peer.NewContext(context.Background(), &peer.Peer{ + Addr: &net.UnixAddr{Name: "/tmp/broker.sock", Net: "unix"}, + }) +} + +func tcpContext() context.Context { + return peer.NewContext(context.Background(), &peer.Peer{ + Addr: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 12345}, + }) +} + +func ref(typeURL string) *anypb.Any { + if typeURL == "" { + return nil + } + return &anypb.Any{TypeUrl: typeURL} +} + +func TestIsTCPCaller(t *testing.T) { + assert.True(t, isTCPCaller(tcpContext())) + assert.False(t, isTCPCaller(udsContext())) + assert.False(t, isTCPCaller(context.Background())) +} + +func TestAuthorizeReferenceType(t *testing.T) { + caller := spiffeid.RequireFromString("spiffe://example.org/broker") + other := spiffeid.RequireFromString("spiffe://example.org/other") + + for _, tt := range []struct { + name string + byCaller map[spiffeid.ID]ReferenceTypePolicy + ctx context.Context + caller spiffeid.ID + ref *anypb.Any + expectCode codes.Code + }{ + { + name: "nil reference is rejected before any gate", + ctx: udsContext(), + caller: caller, + ref: ref(""), + expectCode: codes.InvalidArgument, + }, + { + name: "empty type url is rejected", + ctx: udsContext(), + caller: caller, + ref: &anypb.Any{}, + expectCode: codes.InvalidArgument, + }, + { + name: "caller present and type allowed over UDS", + byCaller: map[spiffeid.ID]ReferenceTypePolicy{caller: { + Types: map[string]ReferenceTypeAccess{k8sType: {}}, + }}, + ctx: udsContext(), + caller: caller, + ref: ref(k8sType), + expectCode: codes.OK, + }, + { + name: "caller present and type not allowed", + byCaller: map[spiffeid.ID]ReferenceTypePolicy{caller: { + Types: map[string]ReferenceTypeAccess{k8sType: {}}, + }}, + ctx: udsContext(), + caller: caller, + ref: ref(pidType), + expectCode: codes.PermissionDenied, + }, + { + name: "caller absent from map behaves as wildcard over UDS", + byCaller: map[spiffeid.ID]ReferenceTypePolicy{other: { + Types: map[string]ReferenceTypeAccess{k8sType: {}}, + }}, + ctx: udsContext(), + caller: caller, + ref: ref(pidType), + expectCode: codes.OK, + }, + { + name: "TCP caller allowed when reference type opts in", + byCaller: map[spiffeid.ID]ReferenceTypePolicy{caller: { + Types: map[string]ReferenceTypeAccess{k8sType: {AllowOverTCP: true}}, + }}, + ctx: tcpContext(), + caller: caller, + ref: ref(k8sType), + expectCode: codes.OK, + }, + { + name: "TCP caller denied when reference type does not opt in", + byCaller: map[spiffeid.ID]ReferenceTypePolicy{caller: { + Types: map[string]ReferenceTypeAccess{k8sType: {}}, + }}, + ctx: tcpContext(), + caller: caller, + ref: ref(k8sType), + expectCode: codes.PermissionDenied, + }, + { + name: "TCP caller denied when caller has no policy", + ctx: tcpContext(), + caller: caller, + ref: ref(k8sType), + expectCode: codes.PermissionDenied, + }, + { + name: "wildcard allows any type over UDS", + byCaller: map[spiffeid.ID]ReferenceTypePolicy{caller: { + AllowAny: true, + }}, + ctx: udsContext(), + caller: caller, + ref: ref(pidType), + expectCode: codes.OK, + }, + { + name: "wildcard allows any type over TCP when it opts in", + byCaller: map[spiffeid.ID]ReferenceTypePolicy{caller: { + AllowAny: true, + AllowAnyOverTCP: true, + }}, + ctx: tcpContext(), + caller: caller, + ref: ref(pidType), + expectCode: codes.OK, + }, + { + name: "wildcard denies TCP by default", + byCaller: map[spiffeid.ID]ReferenceTypePolicy{caller: { + AllowAny: true, + }}, + ctx: tcpContext(), + caller: caller, + ref: ref(pidType), + expectCode: codes.PermissionDenied, + }, + } { + t.Run(tt.name, func(t *testing.T) { + s := New(Config{ + AllowedReferenceTypesByCaller: tt.byCaller, + }) + err := s.authorizeReferenceType(tt.ctx, tt.caller, tt.ref) + if tt.expectCode == codes.OK { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Equal(t, tt.expectCode, status.Code(err)) + }) + } +} diff --git a/pkg/agent/broker/brokercontext/context.go b/pkg/agent/broker/brokercontext/context.go new file mode 100644 index 0000000000..776d3d3698 --- /dev/null +++ b/pkg/agent/broker/brokercontext/context.go @@ -0,0 +1,50 @@ +package brokercontext + +import ( + "context" + "errors" + "fmt" + + "github.com/spiffe/go-spiffe/v2/spiffeid" + "google.golang.org/grpc/metadata" +) + +const callerIDMetadataKey = "spire-agent-broker-caller-id" + +type callerIDKey struct{} + +func WithCallerID(ctx context.Context, id spiffeid.ID) context.Context { + return context.WithValue(ctx, callerIDKey{}, id) +} + +func CallerIDFromContext(ctx context.Context) (spiffeid.ID, bool, error) { + if id, ok := ctx.Value(callerIDKey{}).(spiffeid.ID); ok { + return id, true, nil + } + + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return spiffeid.ID{}, false, nil + } + values := md.Get(callerIDMetadataKey) + switch len(values) { + case 0: + return spiffeid.ID{}, false, nil + case 1: + id, err := spiffeid.FromString(values[0]) + if err != nil { + return spiffeid.ID{}, false, fmt.Errorf("invalid broker caller SPIFFE ID: %w", err) + } + return id, true, nil + default: + return spiffeid.ID{}, false, errors.New("multiple broker caller SPIFFE IDs provided") + } +} + +func AppendCallerIDToOutgoingContext(ctx context.Context) context.Context { + id, ok := ctx.Value(callerIDKey{}).(spiffeid.ID) + if !ok { + return ctx + } + return metadata.AppendToOutgoingContext(ctx, callerIDMetadataKey, id.String()) +} diff --git a/pkg/agent/broker/endpoints.go b/pkg/agent/broker/endpoints.go new file mode 100644 index 0000000000..25714d9172 --- /dev/null +++ b/pkg/agent/broker/endpoints.go @@ -0,0 +1,302 @@ +package broker + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + + "github.com/sirupsen/logrus" + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig" + attestor "github.com/spiffe/spire/pkg/agent/attestor/workload" + brokerapi "github.com/spiffe/spire/pkg/agent/broker/api" + "github.com/spiffe/spire/pkg/agent/endpoints" + "github.com/spiffe/spire/pkg/agent/manager" + "github.com/spiffe/spire/pkg/common/api/middleware" + "github.com/spiffe/spire/pkg/common/telemetry" + "github.com/spiffe/spire/pkg/common/tlspolicy" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/reflection" + "google.golang.org/grpc/status" + + "github.com/spiffe/go-spiffe/v2/bundle/x509bundle" + "github.com/spiffe/go-spiffe/v2/svid/x509svid" + "golang.org/x/sync/errgroup" +) + +type Config struct { + // BindAddrs are the addresses the endpoint listens on. Each may be a + // `*net.UnixAddr` or a `*net.TCPAddr`; the same gRPC server (and same + // mTLS configuration) is fanned out across all of them. + BindAddrs []net.Addr + + Manager manager.Manager + + Log logrus.FieldLogger + + Metrics telemetry.Metrics + + Attestor attestor.Attestor + + // Brokers enumerates the brokers authorized to talk to this endpoint. + // mTLS at the listener gates the set; brokers absent from it are + // rejected at the TLS layer. + Brokers []Broker + + SVIDSource x509svid.Source + BundleSource x509bundle.Source + + // TLSPolicy controls the post-quantum-safe TLS policy applied to the + // inbound mTLS listener. + TLSPolicy tlspolicy.Policy +} + +// Broker identifies a broker authorized to talk to the SPIFFE Broker API +// endpoint, and carries any per-broker configuration. +type Broker struct { + // ID is the SPIFFE ID of the broker. Cross-trust-domain broker + // identities are allowed. + ID string + + // AllowedReferenceTypes restricts which WorkloadReference types this + // broker is permitted to use. Each entry is the verbatim protobuf type + // URL the workload attestor plugin matches against (e.g. + // `type.googleapis.com/spiffe.broker.KubernetesObjectReference`). Use + // `"*"` to allow any reference type this agent's workload attestor stack + // understands. Must list at least one entry. + AllowedReferenceTypes []AllowedReferenceType +} + +// AllowedReferenceType describes a WorkloadReference type the broker may use. +type AllowedReferenceType struct { + // TypeURL is the verbatim protobuf type URL. The wildcard `"*"` allows + // any reference type. + TypeURL string + + // AllowOverTCP permits this reference type over TCP. When false, the + // reference type is only allowed over UDS. + AllowOverTCP bool +} + +type Endpoints struct { + c *Config +} + +func New(c *Config) (*Endpoints, error) { + switch { + case len(c.BindAddrs) == 0: + return nil, errors.New("at least one bind address is required") + case c.Manager == nil: + return nil, errors.New("manager is required") + case c.Log == nil: + return nil, errors.New("log is required") + case c.Metrics == nil: + return nil, errors.New("metrics is required") + case c.Attestor == nil: + return nil, errors.New("attestor is required") + case c.SVIDSource == nil: + return nil, errors.New("SVID source is required") + case c.BundleSource == nil: + return nil, errors.New("bundle source is required") + } + return &Endpoints{ + c: c, + }, nil +} + +func (e *Endpoints) ListenAndServe(ctx context.Context) error { + unaryInterceptor, streamInterceptor := middleware.Interceptors( + middleware.Chain( + endpoints.Middleware(e.c.Log, e.c.Metrics), + middleware.Preprocess(restrictReflectionToUDS), + middleware.Preprocess(verifyBrokerSecurityHeader), + ), + ) + + // TODO(arndt): Delegated Identity API allows to be served without any authorized peer. + // I think it's better to fail as it's a misconfiguration and having that socket up + // without any authorized peer is just a potential security risk. + if len(e.c.Brokers) == 0 { + return errors.New("at least one broker is required") + } + + brokerIDs, err := brokerIDsAsSPIFFEIDs(e.c.Brokers) + if err != nil { + return fmt.Errorf("failed to parse broker IDs: %w", err) + } + + // In comparison to the admin endpoints, this API is secured by mutual TLS using X.509 SVIDs. + // Clients of this API are expected to use the Workload API to obtain their SVIDs first. + // This is to accommodate environments where this API is served over network. + tlsConfig := tlsconfig.MTLSServerConfig(e.c.SVIDSource, e.c.BundleSource, tlsconfig.AuthorizeOneOf(brokerIDs...)) + // Disable session ticket resumption so the peer-authorization callback + // runs on every connection — same rationale as the SPIRE server endpoint. + tlsConfig.SessionTicketsDisabled = true + if err := tlspolicy.ApplyPolicy(tlsConfig, e.c.TLSPolicy); err != nil { + return fmt.Errorf("failed to apply TLS policy: %w", err) + } + server := grpc.NewServer( + grpc.Creds(credentials.NewTLS(tlsConfig)), + grpc.UnaryInterceptor(unaryInterceptor), + grpc.StreamInterceptor(streamInterceptor), + ) + + e.registerBrokerAPI(server) + reflection.Register(server) + + listeners := make([]net.Listener, 0, len(e.c.BindAddrs)) + defer func() { + for _, l := range listeners { + _ = l.Close() + } + }() + for _, addr := range e.c.BindAddrs { + var l net.Listener + switch addr.Network() { + case "unix": + l, err = createUDSListener(addr) + case "tcp": + // TCP is permitted because the SPIFFE Broker API is secured by + // mutual TLS using X.509-SVIDs, so the listener is safe to + // expose over the network. + l, err = net.Listen("tcp", addr.String()) + default: + err = fmt.Errorf("unsupported network type %q for broker endpoint", addr.Network()) + } + if err != nil { + return fmt.Errorf("failed to listen on broker address %q: %w", addr.String(), err) + } + listeners = append(listeners, l) + e.c.Log.WithFields(logrus.Fields{ + telemetry.Network: l.Addr().Network(), + telemetry.Address: l.Addr().String(), + }).Info("Starting SPIFFE Broker Endpoint") + } + + // Fan one gRPC server out across every listener with an errgroup. The + // first goroutine to error (or context cancellation) cancels the + // errgroup's context, which the watcher goroutine uses to call + // server.Stop(), causing every blocked Serve to return. + g, gCtx := errgroup.WithContext(ctx) + for _, l := range listeners { + g.Go(func() error { + if err := server.Serve(l); err != nil && !errors.Is(err, grpc.ErrServerStopped) { + return err + } + return nil + }) + } + g.Go(func() error { + <-gCtx.Done() + e.c.Log.Info("Stopping SPIFFE Broker Endpoint") + server.Stop() + return nil + }) + + if err := g.Wait(); err != nil { + e.c.Log.WithError(err).Error("SPIFFE Broker Endpoint stopped prematurely") + return err + } + e.c.Log.Info("SPIFFE Broker Endpoint has stopped") + return nil +} + +func (e *Endpoints) registerBrokerAPI(server *grpc.Server) { + service := brokerapi.New(brokerapi.Config{ + Manager: e.c.Manager, + Attestor: e.c.Attestor, + Metrics: e.c.Metrics, + Log: e.c.Log.WithField(telemetry.SubsystemName, telemetry.BrokerAPI), + AllowedReferenceTypesByCaller: buildAllowedReferenceTypeMap(e.c.Brokers), + }) + + brokerapi.RegisterService(server, service) +} + +// buildAllowedReferenceTypeMap pre-computes the per-caller policy used by +// the api service to gate WorkloadReference type usage. Brokers whose ID +// fails to parse are skipped; the mTLS authorizer will already have rejected +// them at the listener level since the same parse runs in brokerIDsAsSPIFFEIDs. +func buildAllowedReferenceTypeMap(brokers []Broker) map[spiffeid.ID]brokerapi.ReferenceTypePolicy { + if len(brokers) == 0 { + return nil + } + out := make(map[spiffeid.ID]brokerapi.ReferenceTypePolicy) + for _, b := range brokers { + id, err := spiffeid.FromString(b.ID) + if err != nil { + continue + } + + policy := brokerapi.ReferenceTypePolicy{ + Types: make(map[string]brokerapi.ReferenceTypeAccess, len(b.AllowedReferenceTypes)), + } + for _, allowed := range b.AllowedReferenceTypes { + access := brokerapi.ReferenceTypeAccess{AllowOverTCP: allowed.AllowOverTCP} + if allowed.TypeURL == "*" { + policy.AllowAny = true + policy.AllowAnyOverTCP = allowed.AllowOverTCP + policy.Types = nil + break + } + policy.Types[allowed.TypeURL] = access + } + out[id] = policy + } + return out +} + +func restrictReflectionToUDS(ctx context.Context, fullMethod string, _ any) (context.Context, error) { + if !isReflectionMethod(fullMethod) { + return ctx, nil + } + + p, ok := peer.FromContext(ctx) + if !ok { + return nil, status.Error(codes.Internal, "no peer information available") + } + if p.Addr.Network() != "unix" { + return nil, status.Error(codes.PermissionDenied, "server reflection is only available over Unix sockets") + } + return ctx, nil +} + +func isReflectionMethod(fullMethod string) bool { + return strings.HasPrefix(fullMethod, "/"+middleware.ServerReflectionServiceName+"/") || + strings.HasPrefix(fullMethod, "/"+middleware.ServerReflectionV1AlphaServiceName+"/") +} + +// verifyBrokerSecurityHeader enforces the SPIFFE Broker Endpoint spec +// requirement that every request carries the `broker.spiffe.io: true` gRPC +// metadata header — an SSRF mitigation analogous to the Workload API's +// `workload.spiffe.io` header. Requests missing or mismatching it are +// rejected with InvalidArgument. +func verifyBrokerSecurityHeader(ctx context.Context, _ string, _ any) (context.Context, error) { + md, _ := metadata.FromIncomingContext(ctx) + values := md["broker.spiffe.io"] + if len(values) != 1 || values[0] != "true" { + return nil, status.Error(codes.InvalidArgument, "security header missing from request") + } + return ctx, nil +} + +// brokerIDsAsSPIFFEIDs parses each broker's ID into a spiffeid.ID. Brokers +// from any trust domain are allowed; mTLS at the listener still pins +// authorization to the configured set of identities. +func brokerIDsAsSPIFFEIDs(brokers []Broker) ([]spiffeid.ID, error) { + ids := make([]spiffeid.ID, 0, len(brokers)) + for _, b := range brokers { + id, err := spiffeid.FromString(b.ID) + if err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, nil +} diff --git a/pkg/agent/broker/endpoints_fallback.go b/pkg/agent/broker/endpoints_fallback.go new file mode 100644 index 0000000000..9d46b700cb --- /dev/null +++ b/pkg/agent/broker/endpoints_fallback.go @@ -0,0 +1,12 @@ +//go:build windows + +package broker + +import ( + "errors" + "net" +) + +func createUDSListener(_ net.Addr) (net.Listener, error) { + return nil, errors.New("unsupported platform for broker API") +} diff --git a/pkg/agent/broker/endpoints_posix.go b/pkg/agent/broker/endpoints_posix.go new file mode 100644 index 0000000000..ef0f359c00 --- /dev/null +++ b/pkg/agent/broker/endpoints_posix.go @@ -0,0 +1,29 @@ +//go:build !windows + +package broker + +import ( + "fmt" + "net" + "os" + + "github.com/spiffe/spire/pkg/common/util" +) + +func createUDSListener(bindAddr net.Addr) (net.Listener, error) { + if bindAddr.Network() != "unix" { + return nil, fmt.Errorf("unsupported network type %q for UDS listener", bindAddr.Network()) + } + + // Remove uds if already exists + os.Remove(bindAddr.String()) + + l, err := net.ListenUnix(bindAddr.Network(), util.GetUnixAddr(bindAddr.String())) + if err != nil { + return nil, fmt.Errorf("error creating UDS listener: %w", err) + } + if err := os.Chmod(bindAddr.String(), 0770); err != nil { + return nil, fmt.Errorf("unable to change UDS permissions: %w", err) + } + return l, nil +} diff --git a/pkg/agent/broker/endpoints_test.go b/pkg/agent/broker/endpoints_test.go new file mode 100644 index 0000000000..0b2c91f201 --- /dev/null +++ b/pkg/agent/broker/endpoints_test.go @@ -0,0 +1,182 @@ +package broker + +import ( + "context" + "net" + "testing" + + "github.com/spiffe/go-spiffe/v2/spiffeid" + brokerapi "github.com/spiffe/spire/pkg/agent/broker/api" + "github.com/spiffe/spire/pkg/common/api/middleware" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" +) + +func TestRestrictReflectionToUDS(t *testing.T) { + for _, tt := range []struct { + name string + fullMethod string + addr net.Addr + expectCode codes.Code + }{ + { + name: "non-reflection method without peer is allowed", + fullMethod: "/spiffe.broker.API/FetchX509SVID", + expectCode: codes.OK, + }, + { + name: "reflection over UDS is allowed", + fullMethod: "/" + middleware.ServerReflectionServiceName + "/ServerReflectionInfo", + addr: &net.UnixAddr{Name: "/tmp/broker.sock", Net: "unix"}, + expectCode: codes.OK, + }, + { + name: "reflection v1alpha over UDS is allowed", + fullMethod: "/" + middleware.ServerReflectionV1AlphaServiceName + "/ServerReflectionInfo", + addr: &net.UnixAddr{Name: "/tmp/broker.sock", Net: "unix"}, + expectCode: codes.OK, + }, + { + name: "reflection over TCP is denied", + fullMethod: "/" + middleware.ServerReflectionServiceName + "/ServerReflectionInfo", + addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 8081}, + expectCode: codes.PermissionDenied, + }, + { + name: "reflection without peer is internal", + fullMethod: "/" + middleware.ServerReflectionServiceName + "/ServerReflectionInfo", + expectCode: codes.Internal, + }, + } { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + if tt.addr != nil { + ctx = peer.NewContext(ctx, &peer.Peer{Addr: tt.addr}) + } + + _, err := restrictReflectionToUDS(ctx, tt.fullMethod, nil) + if tt.expectCode == codes.OK { + require.NoError(t, err) + return + } + require.Equal(t, tt.expectCode, status.Code(err)) + }) + } +} + +func TestBuildAllowedReferenceTypeMap(t *testing.T) { + brokerID := spiffeid.RequireFromString("spiffe://example.org/broker") + wildcardID := spiffeid.RequireFromString("spiffe://example.org/wildcard") + k8sType := "type.googleapis.com/spiffe.broker.KubernetesObjectReference" + pidType := "type.googleapis.com/spiffe.broker.WorkloadPIDReference" + + policies := buildAllowedReferenceTypeMap([]Broker{ + { + ID: brokerID.String(), + AllowedReferenceTypes: []AllowedReferenceType{ + {TypeURL: k8sType, AllowOverTCP: true}, + {TypeURL: pidType}, + }, + }, + { + ID: "not a spiffe id", + AllowedReferenceTypes: []AllowedReferenceType{ + {TypeURL: k8sType, AllowOverTCP: true}, + }, + }, + { + ID: wildcardID.String(), + AllowedReferenceTypes: []AllowedReferenceType{ + {TypeURL: "*", AllowOverTCP: true}, + }, + }, + }) + + require.Equal(t, map[spiffeid.ID]brokerapi.ReferenceTypePolicy{ + brokerID: { + Types: map[string]brokerapi.ReferenceTypeAccess{ + k8sType: {AllowOverTCP: true}, + pidType: {}, + }, + }, + wildcardID: { + AllowAny: true, + AllowAnyOverTCP: true, + }, + }, policies) +} + +// TestPreprocessorChain exercises restrictReflectionToUDS and +// verifyBrokerSecurityHeader composed in the same order as the gRPC +// interceptor chain, since the security header is enforced for every +// method including reflection over UDS. +func TestPreprocessorChain(t *testing.T) { + reflectionMethod := "/" + middleware.ServerReflectionServiceName + "/ServerReflectionInfo" + udsAddr := &net.UnixAddr{Name: "/tmp/broker.sock", Net: "unix"} + tcpAddr := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 8081} + + for _, tt := range []struct { + name string + fullMethod string + addr net.Addr + header bool + expectCode codes.Code + }{ + { + name: "reflection over UDS without header is denied", + fullMethod: reflectionMethod, + addr: udsAddr, + header: false, + expectCode: codes.InvalidArgument, + }, + { + name: "reflection over UDS with header is allowed", + fullMethod: reflectionMethod, + addr: udsAddr, + header: true, + expectCode: codes.OK, + }, + { + name: "reflection over TCP is denied before header check", + fullMethod: reflectionMethod, + addr: tcpAddr, + header: true, + expectCode: codes.PermissionDenied, + }, + { + name: "non-reflection method without header is denied", + fullMethod: "/spiffe.broker.API/FetchX509SVID", + addr: udsAddr, + header: false, + expectCode: codes.InvalidArgument, + }, + { + name: "non-reflection method with header is allowed", + fullMethod: "/spiffe.broker.API/FetchX509SVID", + addr: udsAddr, + header: true, + expectCode: codes.OK, + }, + } { + t.Run(tt.name, func(t *testing.T) { + ctx := peer.NewContext(context.Background(), &peer.Peer{Addr: tt.addr}) + if tt.header { + ctx = metadata.NewIncomingContext(ctx, metadata.Pairs("broker.spiffe.io", "true")) + } + + ctx, err := restrictReflectionToUDS(ctx, tt.fullMethod, nil) + if err == nil { + _, err = verifyBrokerSecurityHeader(ctx, tt.fullMethod, nil) + } + + if tt.expectCode == codes.OK { + require.NoError(t, err) + return + } + require.Equal(t, tt.expectCode, status.Code(err)) + }) + } +} diff --git a/pkg/agent/common/cgroups/cgroups.go b/pkg/agent/common/cgroups/cgroups.go index 081f85ee6a..2b242b890d 100644 --- a/pkg/agent/common/cgroups/cgroups.go +++ b/pkg/agent/common/cgroups/cgroups.go @@ -3,16 +3,10 @@ package cgroups import ( "bufio" "fmt" - "io" + "io/fs" "strings" ) -// Filesystem abstracts filesystem operations. -type FileSystem interface { - // Open opens the named file for reading. - Open(name string) (io.ReadCloser, error) -} - // Cgroup represents a linux cgroup. type Cgroup struct { HierarchyID string @@ -28,9 +22,9 @@ type Cgroup struct { // For more information, see: // - http://man7.org/linux/man-pages/man7/cgroups.7.html // - https://www.kernel.org/doc/Documentation/cgroup-v2.txt -func GetCgroups(pid int32, fs FileSystem) ([]Cgroup, error) { - path := fmt.Sprintf("/proc/%v/cgroup", pid) - file, err := fs.Open(path) +func GetCgroups(pid int32, fsys fs.FS) ([]Cgroup, error) { + path := fmt.Sprintf("proc/%v/cgroup", pid) + file, err := fsys.Open(path) if err != nil { return nil, err } diff --git a/pkg/agent/common/cgroups/cgroups_test.go b/pkg/agent/common/cgroups/cgroups_test.go index 28f90ba84a..a6f2ead99e 100644 --- a/pkg/agent/common/cgroups/cgroups_test.go +++ b/pkg/agent/common/cgroups/cgroups_test.go @@ -1,10 +1,9 @@ package cgroups import ( - "io" "os" - "strings" "testing" + "testing/fstest" "github.com/stretchr/testify/require" ) @@ -71,10 +70,8 @@ var ( ) func TestCgroups(t *testing.T) { - cgroups, err := GetCgroups(123, FakeFileSystem{ - Files: map[string]string{ - "/proc/123/cgroup": cgSimple, - }, + cgroups, err := GetCgroups(123, fstest.MapFS{ + "proc/123/cgroup": {Data: []byte(cgSimple)}, }) require.NoError(t, err) require.Len(t, cgroups, 11) @@ -82,40 +79,24 @@ func TestCgroups(t *testing.T) { } func TestCgroupsNotFound(t *testing.T) { - cgroups, err := GetCgroups(123, FakeFileSystem{}) + cgroups, err := GetCgroups(123, fstest.MapFS{}) require.True(t, os.IsNotExist(err)) require.Nil(t, cgroups) } func TestCgroupsBadFormat(t *testing.T) { - cgroups, err := GetCgroups(123, FakeFileSystem{ - Files: map[string]string{ - "/proc/123/cgroup": cgBadFormat, - }, + cgroups, err := GetCgroups(123, fstest.MapFS{ + "proc/123/cgroup": {Data: []byte(cgBadFormat)}, }) require.EqualError(t, err, `invalid cgroup entry, contains 2 colon separated fields but expected at least 3: "11:hugetlb"`) require.Nil(t, cgroups) } func TestUnifiedCgroups(t *testing.T) { - cgroups, err := GetCgroups(1234, FakeFileSystem{ - Files: map[string]string{ - "/proc/1234/cgroup": cgUnified, - }, + cgroups, err := GetCgroups(1234, fstest.MapFS{ + "proc/1234/cgroup": {Data: []byte(cgUnified)}, }) require.NoError(t, err) require.Len(t, cgroups, 11) require.Equal(t, expectUnifiedCgroup, cgroups) } - -type FakeFileSystem struct { - Files map[string]string -} - -func (fs FakeFileSystem) Open(path string) (io.ReadCloser, error) { - data, ok := fs.Files[path] - if !ok { - return nil, os.ErrNotExist - } - return io.NopCloser(strings.NewReader(data)), nil -} diff --git a/pkg/agent/common/cgroups/filesystem.go b/pkg/agent/common/cgroups/filesystem.go deleted file mode 100644 index 6dfe2916cf..0000000000 --- a/pkg/agent/common/cgroups/filesystem.go +++ /dev/null @@ -1,13 +0,0 @@ -package cgroups - -import ( - "io" - "os" -) - -// OSFileSystem implements FileSystem using the local disk -type OSFileSystem struct{} - -func (OSFileSystem) Open(name string) (io.ReadCloser, error) { - return os.Open(name) -} diff --git a/pkg/agent/common/hintsfilter/hintsfilter.go b/pkg/agent/common/hintsfilter/hintsfilter.go new file mode 100644 index 0000000000..50dd8fac79 --- /dev/null +++ b/pkg/agent/common/hintsfilter/hintsfilter.go @@ -0,0 +1,91 @@ +// Package hintsfilter deduplicates registration entries (and the identities +// derived from them) so that callers of the agent's local APIs see at most +// one entry per hint. Used by both the Workload API and the SPIFFE Broker +// API so that hint semantics are consistent across them. +package hintsfilter + +import ( + "github.com/sirupsen/logrus" + "github.com/spiffe/spire/pkg/agent/manager/cache" + "github.com/spiffe/spire/pkg/common/telemetry" + "github.com/spiffe/spire/proto/spire/common" +) + +// FilterRegistrations returns entries with duplicate hints removed, +// preferring the older entry (by CreatedAt) with EntryId as the +// deterministic tie-breaker. +func FilterRegistrations(entries []*common.RegistrationEntry, log logrus.FieldLogger) []*common.RegistrationEntry { + entriesToRemove := getEntriesToRemove(entries, log) + + var filteredEntries []*common.RegistrationEntry + for _, entry := range entries { + if _, ok := entriesToRemove[entry.EntryId]; !ok { + filteredEntries = append(filteredEntries, entry) + } + } + return filteredEntries +} + +// FilterIdentities returns identities whose underlying registration entries +// survive hint deduplication. Same tie-breaking rules as FilterRegistrations. +func FilterIdentities(identities []cache.Identity, log logrus.FieldLogger) []cache.Identity { + entries := make([]*common.RegistrationEntry, 0, len(identities)) + for _, identity := range identities { + entries = append(entries, identity.Entry) + } + entriesToRemove := getEntriesToRemove(entries, log) + + var filteredIdentities []cache.Identity + for _, identity := range identities { + if _, ok := entriesToRemove[identity.Entry.EntryId]; !ok { + filteredIdentities = append(filteredIdentities, identity) + } + } + return filteredIdentities +} + +func getEntriesToRemove(entries []*common.RegistrationEntry, log logrus.FieldLogger) map[string]struct{} { + entriesToRemove := make(map[string]struct{}) + hintsMap := make(map[string]*common.RegistrationEntry) + + for _, entry := range entries { + if entry.Hint == "" { + continue + } + if entryWithNonUniqueHint, ok := hintsMap[entry.Hint]; ok { + entryToMaintain, entryToRemove := hintTieBreaking(entry, entryWithNonUniqueHint) + + hintsMap[entry.Hint] = entryToMaintain + entriesToRemove[entryToRemove.EntryId] = struct{}{} + + log.WithFields(logrus.Fields{ + telemetry.Hint: entryToRemove.Hint, + telemetry.RegistrationID: entryToRemove.EntryId, + }).Warn("Ignoring entry with duplicate hint") + } else { + hintsMap[entry.Hint] = entry + } + } + + return entriesToRemove +} + +func hintTieBreaking(entryA, entryB *common.RegistrationEntry) (maintain, remove *common.RegistrationEntry) { + switch { + case entryA.CreatedAt < entryB.CreatedAt: + maintain = entryA + remove = entryB + case entryA.CreatedAt > entryB.CreatedAt: + maintain = entryB + remove = entryA + default: + if entryA.EntryId < entryB.EntryId { + maintain = entryA + remove = entryB + } else { + maintain = entryB + remove = entryA + } + } + return +} diff --git a/pkg/agent/config.go b/pkg/agent/config.go index 0163d2f7b9..0188344894 100644 --- a/pkg/agent/config.go +++ b/pkg/agent/config.go @@ -5,8 +5,10 @@ import ( "net" "time" - "github.com/sirupsen/logrus" "github.com/spiffe/go-spiffe/v2/spiffeid" + loggerv1 "github.com/spiffe/spire/pkg/agent/api/logger/v1" + "github.com/spiffe/spire/pkg/agent/broker" + "github.com/spiffe/spire/pkg/agent/endpoints" "github.com/spiffe/spire/pkg/agent/trustbundlesources" "github.com/spiffe/spire/pkg/agent/workloadkey" "github.com/spiffe/spire/pkg/common/catalog" @@ -21,6 +23,9 @@ const ( RebootstrapAlways = "always" ) +// WorkloadAPIRateLimitConfig is an alias for endpoints.WorkloadAPIRateLimitConfig. +type WorkloadAPIRateLimitConfig = endpoints.WorkloadAPIRateLimitConfig + type Config struct { // Address to bind the workload api to BindAddress net.Addr @@ -55,7 +60,10 @@ type Config struct { // Configurations for agent plugins PluginConfigs catalog.PluginConfigs - Log logrus.FieldLogger + Log loggerv1.Logger + + // Workload selector prefixes allowed to be included in diagnostic logs. + LogSelectors []string // LogReopener facilitates handling a signal to rotate log file. LogReopener func(context.Context) error @@ -110,11 +118,33 @@ type Config struct { AuthorizedDelegates []string + // Broker holds the SPIFFE Broker API endpoint configuration. Distinct + // from AuthorizedDelegates, which gates the Delegated Identity API. + // Modeled as a struct (rather than a bare slice) so future top-level + // broker configuration can be added without breaking this field's API. + Broker BrokerConfig + // AvailabilityTarget controls how frequently rotate SVIDs AvailabilityTarget time.Duration // TLSPolicy determines the post-quantum-safe TLS policy to apply to all TLS connections. TLSPolicy tlspolicy.Policy + + // WorkloadAPIRateLimit configures per-selector-set rate limiting for Workload API and SDS methods. + WorkloadAPIRateLimit WorkloadAPIRateLimitConfig +} + +// BrokerConfig mirrors the agent's `experimental.broker {}` HCL block. +type BrokerConfig struct { + // BindAddresses are the addresses the broker endpoint listens on. Each + // element is a `*net.UnixAddr` (UDS, POSIX only) or a `*net.TCPAddr`. + // Multiple entries are served simultaneously by a single gRPC server. + // Empty disables the broker endpoint. + BindAddresses []net.Addr + + // Brokers enumerates the brokers authorized to talk to this agent's + // broker endpoint. + Brokers []broker.Broker } func New(c *Config) *Agent { diff --git a/pkg/agent/endpoints/config.go b/pkg/agent/endpoints/config.go index 2bbd30ac25..a3ae40da04 100644 --- a/pkg/agent/endpoints/config.go +++ b/pkg/agent/endpoints/config.go @@ -16,6 +16,17 @@ import ( "google.golang.org/grpc/health/grpc_health_v1" ) +// WorkloadAPIRateLimitConfig configures per-selector-set rate limits for Workload API methods. +// A value of 0 means no limit is applied for that method. +type WorkloadAPIRateLimitConfig struct { + FetchX509SVID int + FetchJWTSVID int + FetchX509Bundles int + FetchJWTBundles int + StreamSecrets int + FetchSecrets int +} + type Config struct { BindAddr net.Addr @@ -43,8 +54,13 @@ type Config struct { AllowedForeignJWTClaims []string + LogSelectors []string + TrustDomain spiffeid.TrustDomain + // WorkloadAPIRateLimit configures per-selector-set rate limiting for Workload API and SDS methods. + WorkloadAPIRateLimit WorkloadAPIRateLimitConfig + // Hooks used by the unit tests to assert that the configuration provided // to each handler is correct and return fake handlers. newWorkloadAPIServer func(workload.Config) workload_pb.SpiffeWorkloadAPIServer diff --git a/pkg/agent/endpoints/endpoints.go b/pkg/agent/endpoints/endpoints.go index e37110bfd2..0dc9e24db6 100644 --- a/pkg/agent/endpoints/endpoints.go +++ b/pkg/agent/endpoints/endpoints.go @@ -66,17 +66,22 @@ func New(c Config) *Endpoints { allowedClaims[claim] = struct{}{} } + workloadRateLimiter := NewWorkloadRateLimiter(c.WorkloadAPIRateLimit, c.Log, c.Metrics) + workloadAPIServer := c.newWorkloadAPIServer(workload.Config{ Manager: c.Manager, Attestor: attestor, + RateLimiter: workloadRateLimiter, AllowUnauthenticatedVerifiers: c.AllowUnauthenticatedVerifiers, AllowedForeignJWTClaims: allowedClaims, + LogSelectors: c.LogSelectors, TrustDomain: c.TrustDomain, }) sdsv3Server := c.newSDSv3Server(sdsv3.Config{ Attestor: attestor, Manager: c.Manager, + RateLimiter: workloadRateLimiter, DefaultSVIDName: c.DefaultSVIDName, DefaultBundleName: c.DefaultBundleName, DefaultAllBundlesName: c.DefaultAllBundlesName, diff --git a/pkg/agent/endpoints/endpoints_test.go b/pkg/agent/endpoints/endpoints_test.go index 846e465c42..cb35987ce7 100644 --- a/pkg/agent/endpoints/endpoints_test.go +++ b/pkg/agent/endpoints/endpoints_test.go @@ -30,6 +30,7 @@ import ( "github.com/spiffe/spire/pkg/common/api/middleware" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/common/util" + "github.com/spiffe/spire/proto/spire/common" "github.com/spiffe/spire/test/fakes/fakemetrics" "github.com/spiffe/spire/test/spiretest" ) @@ -170,12 +171,14 @@ func TestEndpoints(t *testing.T) { DefaultAllBundlesName: "DefaultAllBundlesName", DisableSPIFFECertValidation: true, AllowedForeignJWTClaims: tt.allowedClaims, + LogSelectors: []string{"k8s:ns"}, // Assert the provided config and return a fake Workload API server newWorkloadAPIServer: func(c workload.Config) workload_pb.SpiffeWorkloadAPIServer { attestor, ok := c.Attestor.(PeerTrackerAttestor) require.True(t, ok, "attestor was not a PeerTrackerAttestor wrapper") assert.Equal(t, FakeManager{}, c.Manager) + assert.Equal(t, []string{"k8s:ns"}, c.LogSelectors) if tt.expectClaims != nil { assert.Equal(t, tt.expectClaims, c.AllowedForeignJWTClaims) } else { @@ -250,26 +253,40 @@ type FakeManager struct { } type FakeWorkloadAPIServer struct { - Attestor PeerTrackerAttestor + Attestor PeerTrackerAttestor + RateLimiter workload.RateLimiter workload_pb.UnimplementedSpiffeWorkloadAPIServer } func (s FakeWorkloadAPIServer) FetchJWTSVID(ctx context.Context, _ *workload_pb.JWTSVIDRequest) (*workload_pb.JWTSVIDResponse, error) { - if err := attest(ctx, s.Attestor); err != nil { + selectors, err := attest(ctx, s.Attestor) + if err != nil { return nil, err } + if s.RateLimiter != nil { + if err := s.RateLimiter.RateLimit(workload.MethodFetchJWTSVID, selectors); err != nil { + return nil, err + } + } return &workload_pb.JWTSVIDResponse{}, nil } type FakeSDSv3Server struct { - Attestor PeerTrackerAttestor + Attestor PeerTrackerAttestor + RateLimiter sdsv3.RateLimiter *secret_v3.UnimplementedSecretDiscoveryServiceServer } func (s FakeSDSv3Server) FetchSecrets(ctx context.Context, _ *discovery_v3.DiscoveryRequest) (*discovery_v3.DiscoveryResponse, error) { - if err := attest(ctx, s.Attestor); err != nil { + selectors, err := attest(ctx, s.Attestor) + if err != nil { return nil, err } + if s.RateLimiter != nil { + if err := s.RateLimiter.RateLimit(sdsv3.MethodFetchSecrets, selectors); err != nil { + return nil, err + } + } return &discovery_v3.DiscoveryResponse{}, nil } @@ -277,19 +294,19 @@ type FakeHealthServer struct { grpc_health_v1.UnimplementedHealthServer } -func attest(ctx context.Context, attestor PeerTrackerAttestor) error { +func attest(ctx context.Context, attestor PeerTrackerAttestor) ([]*common.Selector, error) { log := rpccontext.Logger(ctx) selectors, err := attestor.Attest(ctx) if err != nil { log.WithError(err).Error("Failed to attest") - return err + return nil, err } if len(selectors) == 0 { log.Error("Permission denied") - return status.Error(codes.PermissionDenied, "attestor did not return selectors") + return nil, status.Error(codes.PermissionDenied, "attestor did not return selectors") } log.Info("Success") - return nil + return selectors, nil } func logEntryWithPID(level logrus.Level, msg string, keyvalues ...any) spiretest.LogEntry { @@ -314,3 +331,149 @@ func waitForListening(t *testing.T, e *Endpoints, errCh chan error) { assert.Fail(t, err.Error()) } } + +// TestEndpointsWorkloadRateLimitIntegration wires rate limiting through the +// full Endpoints → gRPC stack and verifies that requests are rejected with +// Unavailable once the per-caller burst is exhausted. +func TestEndpointsWorkloadRateLimitIntegration(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + log, _ := test.NewNullLogger() + fm := fakemetrics.New() + addr := getTestAddr(t) + + e := New(Config{ + BindAddr: addr, + Log: log, + Metrics: fm, + Attestor: FakeAttestor{}, + Manager: FakeManager{}, + DefaultSVIDName: "DefaultSVIDName", + DefaultBundleName: "DefaultBundleName", + DefaultAllBundlesName: "DefaultAllBundlesName", + DisableSPIFFECertValidation: true, + WorkloadAPIRateLimit: WorkloadAPIRateLimitConfig{ + FetchJWTSVID: 1, + }, + newWorkloadAPIServer: func(c workload.Config) workload_pb.SpiffeWorkloadAPIServer { + return FakeWorkloadAPIServer{Attestor: c.Attestor.(PeerTrackerAttestor), RateLimiter: c.RateLimiter} + }, + newSDSv3Server: func(c sdsv3.Config) secret_v3.SecretDiscoveryServiceServer { + return FakeSDSv3Server{Attestor: c.Attestor.(PeerTrackerAttestor)} + }, + newHealthServer: func(c healthv1.Config) grpc_health_v1.HealthServer { + return FakeHealthServer{} + }, + }) + e.hooks.listening = make(chan struct{}) + + serveCtx, serveCancel := context.WithCancel(ctx) + defer serveCancel() + + errCh := make(chan error, 1) + go func() { + errCh <- e.ListenAndServe(serveCtx) + }() + defer func() { + serveCancel() + assert.NoError(t, <-errCh) + }() + waitForListening(t, e, errCh) + + target, err := util.GetTargetName(e.addr) + require.NoError(t, err) + + conn, err := util.NewGRPCClient(target) + require.NoError(t, err) + defer conn.Close() + + wlClient := workload_pb.NewSpiffeWorkloadAPIClient(conn) + callCtx := metadata.NewOutgoingContext(ctx, metadata.Pairs("workload.spiffe.io", "true")) + + // First call is within the burst of 1 and must succeed. + _, err = wlClient.FetchJWTSVID(callCtx, &workload_pb.JWTSVIDRequest{}) + require.NoError(t, err) + + // Second call exhausts the burst and must be rejected. + _, err = wlClient.FetchJWTSVID(callCtx, &workload_pb.JWTSVIDRequest{}) + spiretest.AssertGRPCStatusContains(t, err, codes.Unavailable, "rate limit exceeded") + + // Verify the rate_limit_exceeded metric was emitted for the rejected call. + found := false + for _, item := range fm.AllMetrics() { + if item.Type == fakemetrics.IncrCounterWithLabelsType && + len(item.Key) == 2 && item.Key[0] == "workload_api" && item.Key[1] == "rate_limit_exceeded" { + found = true + break + } + } + assert.True(t, found, "rate_limit_exceeded metric should be emitted on rejection") +} + +// TestEndpointsSDSv3RateLimitIntegration verifies that the rate limiter is +// wired through the full Endpoints → sdsv3.Config path. +func TestEndpointsSDSv3RateLimitIntegration(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + log, _ := test.NewNullLogger() + fm := fakemetrics.New() + addr := getTestAddr(t) + + e := New(Config{ + BindAddr: addr, + Log: log, + Metrics: fm, + Attestor: FakeAttestor{}, + Manager: FakeManager{}, + DefaultSVIDName: "DefaultSVIDName", + DefaultBundleName: "DefaultBundleName", + DefaultAllBundlesName: "DefaultAllBundlesName", + DisableSPIFFECertValidation: true, + WorkloadAPIRateLimit: WorkloadAPIRateLimitConfig{ + FetchSecrets: 1, + }, + newWorkloadAPIServer: func(c workload.Config) workload_pb.SpiffeWorkloadAPIServer { + return FakeWorkloadAPIServer{Attestor: c.Attestor.(PeerTrackerAttestor)} + }, + newSDSv3Server: func(c sdsv3.Config) secret_v3.SecretDiscoveryServiceServer { + return FakeSDSv3Server{Attestor: c.Attestor.(PeerTrackerAttestor), RateLimiter: c.RateLimiter} + }, + newHealthServer: func(c healthv1.Config) grpc_health_v1.HealthServer { + return FakeHealthServer{} + }, + }) + e.hooks.listening = make(chan struct{}) + + serveCtx, serveCancel := context.WithCancel(ctx) + defer serveCancel() + + errCh := make(chan error, 1) + go func() { + errCh <- e.ListenAndServe(serveCtx) + }() + defer func() { + serveCancel() + assert.NoError(t, <-errCh) + }() + waitForListening(t, e, errCh) + + target, err := util.GetTargetName(e.addr) + require.NoError(t, err) + + conn, err := util.NewGRPCClient(target) + require.NoError(t, err) + defer conn.Close() + + sdsClient := secret_v3.NewSecretDiscoveryServiceClient(conn) + callCtx := metadata.NewOutgoingContext(ctx, metadata.Pairs("workload.spiffe.io", "true")) + + // First call is within the burst of 1 and must succeed. + _, err = sdsClient.FetchSecrets(callCtx, &discovery_v3.DiscoveryRequest{}) + require.NoError(t, err) + + // Second call exhausts the burst and must be rejected. + _, err = sdsClient.FetchSecrets(callCtx, &discovery_v3.DiscoveryRequest{}) + spiretest.AssertGRPCStatusContains(t, err, codes.Unavailable, "rate limit exceeded") +} diff --git a/pkg/agent/endpoints/metrics.go b/pkg/agent/endpoints/metrics.go index 6d27bd04b9..a13c091b3b 100644 --- a/pkg/agent/endpoints/metrics.go +++ b/pkg/agent/endpoints/metrics.go @@ -23,6 +23,7 @@ type connectionMetrics struct { workloadAPIConns int32 sdsAPIConns int32 debugAPIConns int32 + loggerAPIConns int32 delegatedIdentityAPIConns int32 } @@ -41,6 +42,9 @@ func (m *connectionMetrics) Preprocess(ctx context.Context, _ string, _ any) (co case middleware.DebugServiceName: adminapi.IncrDebugAPIConnectionCounter(m.metrics) adminapi.SetDebugAPIConnectionGauge(m.metrics, atomic.AddInt32(&m.debugAPIConns, 1)) + case middleware.AgentLoggerServiceName: + adminapi.IncrLoggerAPIConnectionCounter(m.metrics) + adminapi.SetLoggerAPIConnectionGauge(m.metrics, atomic.AddInt32(&m.loggerAPIConns, 1)) case middleware.HealthServiceName, middleware.ServerReflectionServiceName, middleware.ServerReflectionV1AlphaServiceName: // Intentionally not emitting metrics for health and reflection services default: @@ -61,6 +65,8 @@ func (m *connectionMetrics) Postprocess(ctx context.Context, _ string, _ bool, _ adminapi.SetDelegatedIdentityAPIConnectionGauge(m.metrics, atomic.AddInt32(&m.delegatedIdentityAPIConns, -1)) case middleware.DebugServiceName: adminapi.SetDebugAPIConnectionGauge(m.metrics, atomic.AddInt32(&m.debugAPIConns, -1)) + case middleware.AgentLoggerServiceName: + adminapi.SetLoggerAPIConnectionGauge(m.metrics, atomic.AddInt32(&m.loggerAPIConns, -1)) case middleware.HealthServiceName, middleware.ServerReflectionServiceName, middleware.ServerReflectionV1AlphaServiceName: // Intentionally not emitting metrics for health and reflection services default: diff --git a/pkg/agent/endpoints/metrics_test.go b/pkg/agent/endpoints/metrics_test.go index 6232a90f4a..d7e5839f06 100644 --- a/pkg/agent/endpoints/metrics_test.go +++ b/pkg/agent/endpoints/metrics_test.go @@ -12,6 +12,7 @@ import ( workload_pb "github.com/spiffe/go-spiffe/v2/proto/spiffe/workload" debugv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/agent/debug/v1" delegatedidentityv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/agent/delegatedidentity/v1" + loggerv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/agent/logger/v1" "github.com/spiffe/spire/pkg/common/peertracker" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -91,6 +92,7 @@ func TestAllAgentServicesHandledByConnectionMetrics(t *testing.T) { secret_v3.RegisterSecretDiscoveryServiceServer(s, &secret_v3.UnimplementedSecretDiscoveryServiceServer{}) grpc_health_v1.RegisterHealthServer(s, &grpc_health_v1.UnimplementedHealthServer{}) debugv1.RegisterDebugServer(s, &fakeDebugServer{}) + loggerv1.RegisterLoggerServer(s, &fakeLoggerServer{}) delegatedidentityv1.RegisterDelegatedIdentityServer(s, &fakeDelegatedIdentityServer{}) }, grpctest.Middleware(Middleware(log, metrics)), @@ -129,6 +131,13 @@ func TestAllAgentServicesHandledByConnectionMetrics(t *testing.T) { assertNoMisconfigurationLog(t, hook) }) + t.Run("Logger", func(t *testing.T) { + hook.Reset() + client := loggerv1.NewLoggerClient(conn) + _, _ = client.GetLogger(ctx, &loggerv1.GetLoggerRequest{}) + assertNoMisconfigurationLog(t, hook) + }) + t.Run("DelegatedIdentity", func(t *testing.T) { hook.Reset() client := delegatedidentityv1.NewDelegatedIdentityClient(conn) @@ -248,6 +257,10 @@ type fakeDelegatedIdentityServer struct { delegatedidentityv1.UnimplementedDelegatedIdentityServer } +type fakeLoggerServer struct { + loggerv1.UnimplementedLoggerServer +} + type fakeWatcherWithPID int func (w fakeWatcherWithPID) Close() {} diff --git a/pkg/agent/endpoints/peertracker_test.go b/pkg/agent/endpoints/peertracker_test.go index 3f559c2ccc..95e2d97a42 100644 --- a/pkg/agent/endpoints/peertracker_test.go +++ b/pkg/agent/endpoints/peertracker_test.go @@ -12,6 +12,8 @@ import ( "github.com/stretchr/testify/assert" "google.golang.org/grpc/codes" "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/anypb" ) func TestPeerTrackerAttestor(t *testing.T) { @@ -44,6 +46,10 @@ func (a FakeAttestor) Attest(_ context.Context, pid int) ([]*common.Selector, er return nil, nil } +func (a FakeAttestor) AttestReference(_ context.Context, _ *anypb.Any) ([]*common.Selector, error) { + return nil, status.Error(codes.Unimplemented, "AttestReference not implemented") +} + func WithFakeWatcher(alive bool) context.Context { return peer.NewContext(context.Background(), &peer.Peer{ AuthInfo: peertracker.AuthInfo{ diff --git a/pkg/agent/endpoints/ratelimit.go b/pkg/agent/endpoints/ratelimit.go new file mode 100644 index 0000000000..1b5e4cf0b9 --- /dev/null +++ b/pkg/agent/endpoints/ratelimit.go @@ -0,0 +1,129 @@ +package endpoints + +import ( + "crypto/sha256" + "io" + "slices" + + "github.com/sirupsen/logrus" + "github.com/spiffe/spire/pkg/agent/endpoints/sdsv3" + "github.com/spiffe/spire/pkg/agent/endpoints/workload" + "github.com/spiffe/spire/pkg/common/ratelimit" + "github.com/spiffe/spire/pkg/common/telemetry" + "github.com/spiffe/spire/pkg/common/telemetry/agent/workloadapi" + "github.com/spiffe/spire/pkg/common/util" + "github.com/spiffe/spire/proto/spire/common" + "golang.org/x/time/rate" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// perCallerRateLimiterOpts holds options for creating per-caller rate limiters. +// Exposed for testing (e.g., clock injection). +var perCallerRateLimiterOpts []ratelimit.Option + +// perCallerRateLimiter wraps a ratelimit.PerKeyLimiter to provide a simple +// Allow(key) API for rate limiting by caller identity. +type perCallerRateLimiter struct { + inner *ratelimit.PerKeyLimiter +} + +func newPerCallerRateLimiter(limit int) *perCallerRateLimiter { + return &perCallerRateLimiter{ + inner: ratelimit.NewPerKeyLimiter(func() ratelimit.Limiter { + return rate.NewLimiter(rate.Limit(limit), limit) + }, perCallerRateLimiterOpts...), + } +} + +// Allow reports whether 1 event may happen at the current time for the given key. +func (l *perCallerRateLimiter) Allow(key string) bool { + limiter := l.inner.GetLimiter(key) + return limiter.AllowN(l.inner.Now(), 1) +} + +// selectorSetKey builds a collision-resistant key from a selector set by +// hashing the sorted type/value pairs with SHA-256. Hashing keeps the map key a +// fixed, small size regardless of how many selectors the caller has. Selectors +// are sorted for stability, and a nul byte is written after each item to avoid +// ambiguity between adjacent items. The raw digest is returned as a string; +// it's only ever used as a map key, so it does not need to be printable. Empty +// input returns "". +func selectorSetKey(selectors []*common.Selector) string { + if len(selectors) == 0 { + return "" + } + sorted := slices.Clone(selectors) + util.SortSelectors(sorted) + + h := sha256.New() + for _, s := range sorted { + _, _ = io.WriteString(h, s.Type) + h.Write([]byte{0}) + _, _ = io.WriteString(h, s.Value) + h.Write([]byte{0}) + } + return string(h.Sum(nil)) +} + +// WorkloadRateLimiter enforces per-selector-set rate limiting on Workload API +// methods. It is called from the handler after workload attestation, once the +// caller's attested selectors are known. +type WorkloadRateLimiter struct { + limiters map[string]*perCallerRateLimiter + metrics telemetry.Metrics +} + +// RateLimit checks whether the request for fullMethod should be allowed given +// the caller's attested selectors. The selector set is treated as a single key, +// so all workloads with the same selector set share one token bucket. Callers +// with no selectors share an "" bucket. Methods without a +// configured limit pass through. +func (r *WorkloadRateLimiter) RateLimit(fullMethod string, selectors []*common.Selector) error { + limiter, ok := r.limiters[fullMethod] + if !ok { + return nil + } + key := selectorSetKey(selectors) + if !limiter.Allow(key) { + workloadapi.IncrRateLimitExceededCounter(r.metrics, fullMethod) + return status.Errorf(codes.Unavailable, "rate limit exceeded for %s", fullMethod) + } + return nil +} + +// NewWorkloadRateLimiter creates a rate limiter from the given config. Methods +// with a zero limit are omitted from the limiters map and pass through at +// RateLimit time; if no methods are configured the result is effectively a +// no-op for every call. +func NewWorkloadRateLimiter(cfg WorkloadAPIRateLimitConfig, log logrus.FieldLogger, metrics telemetry.Metrics) *WorkloadRateLimiter { + type methodLimit struct { + method string + limit int + } + + methods := []methodLimit{ + {workload.MethodFetchX509SVID, cfg.FetchX509SVID}, + {workload.MethodFetchJWTSVID, cfg.FetchJWTSVID}, + {workload.MethodFetchX509Bundles, cfg.FetchX509Bundles}, + {workload.MethodFetchJWTBundles, cfg.FetchJWTBundles}, + {sdsv3.MethodStreamSecrets, cfg.StreamSecrets}, + {sdsv3.MethodFetchSecrets, cfg.FetchSecrets}, + } + + limiters := make(map[string]*perCallerRateLimiter) + for _, ml := range methods { + if ml.limit > 0 { + limiters[ml.method] = newPerCallerRateLimiter(ml.limit) + log.WithFields(logrus.Fields{ + telemetry.Method: ml.method, + telemetry.Limit: ml.limit, + }).Info("Workload API/SDS rate limiting enabled") + } + } + + return &WorkloadRateLimiter{ + limiters: limiters, + metrics: metrics, + } +} diff --git a/pkg/agent/endpoints/ratelimit_test.go b/pkg/agent/endpoints/ratelimit_test.go new file mode 100644 index 0000000000..1edc621088 --- /dev/null +++ b/pkg/agent/endpoints/ratelimit_test.go @@ -0,0 +1,363 @@ +package endpoints + +import ( + "fmt" + "sync" + "testing" + "time" + + "github.com/sirupsen/logrus/hooks/test" + "github.com/spiffe/spire/pkg/agent/endpoints/sdsv3" + "github.com/spiffe/spire/pkg/agent/endpoints/workload" + "github.com/spiffe/spire/pkg/common/ratelimit" + "github.com/spiffe/spire/pkg/common/telemetry" + "github.com/spiffe/spire/proto/spire/common" + testclock "github.com/spiffe/spire/test/clock" + "github.com/spiffe/spire/test/fakes/fakemetrics" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func setupTestClock(t *testing.T) *testclock.Mock { + mockClk := testclock.NewMock(t) + oldOpts := perCallerRateLimiterOpts + perCallerRateLimiterOpts = []ratelimit.Option{ratelimit.WithClock(mockClk)} + t.Cleanup(func() { perCallerRateLimiterOpts = oldOpts }) + return mockClk +} + +func TestPerCallerRateLimiterAllow(t *testing.T) { + lim := newPerCallerRateLimiter(2) + + // First two events for key are allowed (limit=2, burst=2). + assert.True(t, lim.Allow("spiffe://example.org/foo")) + assert.True(t, lim.Allow("spiffe://example.org/foo")) + // Third event is denied. + assert.False(t, lim.Allow("spiffe://example.org/foo")) +} + +func TestPerCallerRateLimiterIndependence(t *testing.T) { + lim := newPerCallerRateLimiter(1) + + // Different keys have independent token buckets. + assert.True(t, lim.Allow("spiffe://example.org/foo")) + assert.True(t, lim.Allow("spiffe://example.org/bar")) + // Both keys are now exhausted. + assert.False(t, lim.Allow("spiffe://example.org/foo")) + assert.False(t, lim.Allow("spiffe://example.org/bar")) +} + +func TestPerCallerRateLimiterGC(t *testing.T) { + mockClk := setupTestClock(t) + + lim := newPerCallerRateLimiter(1) + + // Exhaust key at time T. + assert.True(t, lim.Allow("spiffe://example.org/foo")) + assert.False(t, lim.Allow("spiffe://example.org/foo")) + + // Advance past the GC interval and trigger GC by accessing another key. + mockClk.Add(ratelimit.GCInterval) + assert.True(t, lim.Allow("spiffe://example.org/bar")) + + // Advance past the GC interval again and trigger GC. + mockClk.Add(ratelimit.GCInterval) + assert.True(t, lim.Allow("spiffe://example.org/baz")) + + // Original key has been GC'd. A new limiter is created with a fresh token bucket. + assert.True(t, lim.Allow("spiffe://example.org/foo")) +} + +func TestPerCallerRateLimiterPreviousPreservation(t *testing.T) { + mockClk := setupTestClock(t) + + lim := newPerCallerRateLimiter(1) + + assert.True(t, lim.Allow("spiffe://example.org/foo")) + assert.True(t, lim.Allow("spiffe://example.org/bar")) + + // Advance past GC interval and trigger GC. + mockClk.Add(ratelimit.GCInterval) + assert.True(t, lim.Allow("spiffe://example.org/baz")) + + // Access foo — it gets promoted from previous to current (same limiter). + lim.Allow("spiffe://example.org/foo") + + // Advance past GC interval again and trigger GC. + mockClk.Add(ratelimit.GCInterval) + assert.True(t, lim.Allow("spiffe://example.org/qux")) + + // bar is gone; accessing it creates a fresh limiter. + assert.True(t, lim.Allow("spiffe://example.org/bar")) +} + +func TestPerCallerRateLimiterTokenRefill(t *testing.T) { + mockClk := setupTestClock(t) + + lim := newPerCallerRateLimiter(2) + + // Exhaust tokens. + assert.True(t, lim.Allow("spiffe://example.org/foo")) + assert.True(t, lim.Allow("spiffe://example.org/foo")) + assert.False(t, lim.Allow("spiffe://example.org/foo")) + + // Advance time by 1 second: 2 new tokens available (rate=2/s). + mockClk.Add(time.Second) + assert.True(t, lim.Allow("spiffe://example.org/foo")) + assert.True(t, lim.Allow("spiffe://example.org/foo")) + assert.False(t, lim.Allow("spiffe://example.org/foo")) +} + +// TestPerCallerRateLimiterConcurrency exercises the perCallerRateLimiter from +// many goroutines concurrently. Run with -race to detect data races. +func TestPerCallerRateLimiterConcurrency(t *testing.T) { + const goroutines = 50 + const callsPerGoroutine = 200 + + lim := newPerCallerRateLimiter(10) + + var wg sync.WaitGroup + wg.Add(goroutines) + for i := range goroutines { + go func(id int) { + defer wg.Done() + // Use 5 shared keys so goroutines contend on the same buckets. + key := fmt.Sprintf("spiffe://example.org/workload-%d", id%5) + for range callsPerGoroutine { + lim.Allow(key) + } + }(i) + } + wg.Wait() +} + +// selectors builds a []*common.Selector from alternating type/value strings. +func selectors(pairs ...string) []*common.Selector { + if len(pairs)%2 != 0 { + panic("selectors: odd number of arguments") + } + out := make([]*common.Selector, 0, len(pairs)/2) + for len(pairs) >= 2 { + out = append(out, &common.Selector{Type: pairs[0], Value: pairs[1]}) + pairs = pairs[2:] + } + return out +} + +func TestSelectorSetKey(t *testing.T) { + // Same selectors in different order produce the same key. + s1 := selectors("k8s", "pod:a", "unix", "uid:1000") + s2 := selectors("unix", "uid:1000", "k8s", "pod:a") + assert.Equal(t, selectorSetKey(s1), selectorSetKey(s2)) + + // Different selector sets produce different keys. + s3 := selectors("k8s", "pod:b", "unix", "uid:1000") + assert.NotEqual(t, selectorSetKey(s1), selectorSetKey(s3)) + + // Selectors whose concatenation would collide without separators map to + // distinct keys (type "a"/value "bc" vs type "ab"/value "c"). + assert.NotEqual(t, selectorSetKey(selectors("a", "bc")), selectorSetKey(selectors("ab", "c"))) + + // Empty selector set maps to the shared unattested bucket. + assert.Equal(t, "", selectorSetKey(nil)) + assert.Equal(t, "", selectorSetKey([]*common.Selector{})) +} + +func TestWorkloadRateLimiterRateLimit(t *testing.T) { + log, _ := test.NewNullLogger() + metrics := telemetry.Blackhole{} + cfg := WorkloadAPIRateLimitConfig{ + FetchX509SVID: 2, + } + rl := NewWorkloadRateLimiter(cfg, log, metrics) + require.NotNil(t, rl) + + sel := selectors("k8s", "pod:foo") + + // First two calls are allowed. + require.NoError(t, rl.RateLimit(workload.MethodFetchX509SVID, sel)) + require.NoError(t, rl.RateLimit(workload.MethodFetchX509SVID, sel)) + + // Third call is rejected. + err := rl.RateLimit(workload.MethodFetchX509SVID, sel) + require.Error(t, err) + assert.Equal(t, codes.Unavailable, status.Code(err)) + assert.Contains(t, status.Convert(err).Message(), "rate limit exceeded") +} + +func TestWorkloadRateLimiterUnconfiguredMethod(t *testing.T) { + log, _ := test.NewNullLogger() + metrics := telemetry.Blackhole{} + cfg := WorkloadAPIRateLimitConfig{ + FetchX509SVID: 1, + } + rl := NewWorkloadRateLimiter(cfg, log, metrics) + require.NotNil(t, rl) + + sel := selectors("k8s", "pod:foo") + + // Exhaust FetchX509SVID. + require.NoError(t, rl.RateLimit(workload.MethodFetchX509SVID, sel)) + require.Error(t, rl.RateLimit(workload.MethodFetchX509SVID, sel)) + + // FetchJWTSVID is not configured; it passes through. + require.NoError(t, rl.RateLimit(workload.MethodFetchJWTSVID, sel)) +} + +func TestWorkloadRateLimiterNonWorkloadMethod(t *testing.T) { + log, _ := test.NewNullLogger() + metrics := telemetry.Blackhole{} + cfg := WorkloadAPIRateLimitConfig{ + FetchX509SVID: 1, + } + rl := NewWorkloadRateLimiter(cfg, log, metrics) + require.NotNil(t, rl) + + sel := selectors("k8s", "pod:foo") + + // Non-Workload API methods always pass through. + require.NoError(t, rl.RateLimit("/grpc.health.v1.Health/Check", sel)) + require.NoError(t, rl.RateLimit("/envoy.service.secret.v3.SecretDiscoveryService/FetchSecrets", sel)) +} + +func TestNewWorkloadRateLimiterAllZero(t *testing.T) { + log, _ := test.NewNullLogger() + metrics := telemetry.Blackhole{} + // All-zero config still returns a usable limiter; every method passes through. + rl := NewWorkloadRateLimiter(WorkloadAPIRateLimitConfig{}, log, metrics) + require.NotNil(t, rl) + assert.Empty(t, rl.limiters) + require.NoError(t, rl.RateLimit(workload.MethodFetchX509SVID, selectors("k8s", "pod:foo"))) + require.NoError(t, rl.RateLimit(workload.MethodFetchJWTSVID, nil)) +} + +func TestNewWorkloadRateLimiterBothMethods(t *testing.T) { + log, _ := test.NewNullLogger() + metrics := telemetry.Blackhole{} + cfg := WorkloadAPIRateLimitConfig{ + FetchX509SVID: 1, + FetchJWTSVID: 3, + } + rl := NewWorkloadRateLimiter(cfg, log, metrics) + require.NotNil(t, rl) + assert.Len(t, rl.limiters, 2) + assert.Contains(t, rl.limiters, workload.MethodFetchX509SVID) + assert.Contains(t, rl.limiters, workload.MethodFetchJWTSVID) +} + +func TestNewWorkloadRateLimiterAllSixMethods(t *testing.T) { + log, _ := test.NewNullLogger() + metrics := telemetry.Blackhole{} + cfg := WorkloadAPIRateLimitConfig{ + FetchX509SVID: 1, + FetchJWTSVID: 2, + FetchX509Bundles: 3, + FetchJWTBundles: 4, + StreamSecrets: 5, + FetchSecrets: 6, + } + rl := NewWorkloadRateLimiter(cfg, log, metrics) + require.NotNil(t, rl) + assert.Len(t, rl.limiters, 6) + assert.Contains(t, rl.limiters, workload.MethodFetchX509SVID) + assert.Contains(t, rl.limiters, workload.MethodFetchJWTSVID) + assert.Contains(t, rl.limiters, workload.MethodFetchX509Bundles) + assert.Contains(t, rl.limiters, workload.MethodFetchJWTBundles) + assert.Contains(t, rl.limiters, sdsv3.MethodStreamSecrets) + assert.Contains(t, rl.limiters, sdsv3.MethodFetchSecrets) +} + +// TestWorkloadRateLimiterSelectorSetIndependence verifies that two callers +// with different selector sets each have independent token buckets. +func TestWorkloadRateLimiterSelectorSetIndependence(t *testing.T) { + log, _ := test.NewNullLogger() + metrics := telemetry.Blackhole{} + cfg := WorkloadAPIRateLimitConfig{ + FetchJWTSVID: 1, + } + rl := NewWorkloadRateLimiter(cfg, log, metrics) + require.NotNil(t, rl) + + selA := selectors("k8s", "pod:a") + selB := selectors("k8s", "pod:b") + + // Exhaust pod:a bucket. + require.NoError(t, rl.RateLimit(workload.MethodFetchJWTSVID, selA)) + require.Error(t, rl.RateLimit(workload.MethodFetchJWTSVID, selA)) + + // pod:b has its own bucket and is still allowed. + require.NoError(t, rl.RateLimit(workload.MethodFetchJWTSVID, selB)) +} + +// TestWorkloadRateLimiterSelectorSetOrdering verifies that selectors with the +// same elements in different order map to the same bucket. +func TestWorkloadRateLimiterSelectorSetOrdering(t *testing.T) { + log, _ := test.NewNullLogger() + metrics := telemetry.Blackhole{} + cfg := WorkloadAPIRateLimitConfig{ + FetchJWTSVID: 1, + } + rl := NewWorkloadRateLimiter(cfg, log, metrics) + require.NotNil(t, rl) + + // Same selectors, different order — must share one bucket. + sel1 := selectors("k8s", "pod:x", "unix", "uid:500") + sel2 := selectors("unix", "uid:500", "k8s", "pod:x") + + require.NoError(t, rl.RateLimit(workload.MethodFetchJWTSVID, sel1)) + // sel2 resolves to the same key, so the bucket is exhausted. + require.Error(t, rl.RateLimit(workload.MethodFetchJWTSVID, sel2)) +} + +// TestWorkloadRateLimiterUnattestedBucket verifies that callers with no +// selectors share a single "" bucket. +func TestWorkloadRateLimiterUnattestedBucket(t *testing.T) { + log, _ := test.NewNullLogger() + metrics := telemetry.Blackhole{} + cfg := WorkloadAPIRateLimitConfig{ + FetchJWTSVID: 1, + } + rl := NewWorkloadRateLimiter(cfg, log, metrics) + require.NotNil(t, rl) + + // First nil-selector call consumes the single token. + require.NoError(t, rl.RateLimit(workload.MethodFetchJWTSVID, nil)) + + // A second nil-selector call (different caller, same empty set) is rejected + // because they share the "" bucket. + require.Error(t, rl.RateLimit(workload.MethodFetchJWTSVID, []*common.Selector{})) +} + +// TestWorkloadRateLimiterMetricsOnRejection verifies that +// IncrRateLimitExceededCounter is called with the correct method label +// when a request is rejected. +func TestWorkloadRateLimiterMetricsOnRejection(t *testing.T) { + log, _ := test.NewNullLogger() + fm := fakemetrics.New() + + cfg := WorkloadAPIRateLimitConfig{FetchJWTSVID: 1} + rl := NewWorkloadRateLimiter(cfg, log, fm) + require.NotNil(t, rl) + + sel := selectors("k8s", "pod:foo") + + // First call is allowed — no rate limit metric emitted. + require.NoError(t, rl.RateLimit(workload.MethodFetchJWTSVID, sel)) + assert.Empty(t, fm.AllMetrics(), "no metric expected when request is allowed") + + // Second call is rejected — rate limit metric must be emitted. + err := rl.RateLimit(workload.MethodFetchJWTSVID, sel) + require.Error(t, err) + + items := fm.AllMetrics() + require.Len(t, items, 1) + assert.Equal(t, fakemetrics.IncrCounterWithLabelsType, items[0].Type) + assert.Equal(t, []string{telemetry.WorkloadAPI, telemetry.RateLimitExceeded}, items[0].Key) + assert.Equal(t, float64(1), items[0].Val) + require.Len(t, items[0].Labels, 1) + assert.Equal(t, telemetry.Method, items[0].Labels[0].Name) + // Label values are sanitized by the telemetry layer: '/' → '_'. + assert.Equal(t, "_SpiffeWorkloadAPI_FetchJWTSVID", items[0].Labels[0].Value) +} diff --git a/pkg/agent/endpoints/sdsv3/handler.go b/pkg/agent/endpoints/sdsv3/handler.go index 553469663d..a23b92069e 100644 --- a/pkg/agent/endpoints/sdsv3/handler.go +++ b/pkg/agent/endpoints/sdsv3/handler.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "maps" + "os" "sort" "strconv" @@ -31,8 +32,16 @@ import ( const ( disableSPIFFECertValidationKey = "disable_spiffe_cert_validation" + + MethodStreamSecrets = "/envoy.service.secret.v3.SecretDiscoveryService/StreamSecrets" + MethodFetchSecrets = "/envoy.service.secret.v3.SecretDiscoveryService/FetchSecrets" ) +// RateLimiter enforces per-selector-set rate limiting on SDS methods. +type RateLimiter interface { + RateLimit(fullMethod string, selectors []*common.Selector) error +} + type Attestor interface { Attest(ctx context.Context) ([]*common.Selector, error) } @@ -45,6 +54,7 @@ type Manager interface { type Config struct { Attestor Attestor Manager Manager + RateLimiter RateLimiter DefaultAllBundlesName string DefaultBundleName string DefaultSVIDName string @@ -64,12 +74,35 @@ func New(config Config) *Handler { return &Handler{c: config} } +func (h *Handler) rateLimit(ctx context.Context, fullMethod string, selectors []*common.Selector) error { + if h.c.RateLimiter == nil { + return nil + } + // Exempt the agent's own calls from rate limiting so that an + // operator-configured limit can't deny the agent itself (e.g. health + // probes) and mark it unhealthy. Mirrors the Workload API handler. + if isAgent(ctx) { + return nil + } + return h.c.RateLimiter.RateLimit(fullMethod, selectors) +} + +// isAgent returns true if the caller PID from the provided context is the +// agent's own PID. +func isAgent(ctx context.Context) bool { + return rpccontext.CallerPID(ctx) == os.Getpid() +} + func (h *Handler) StreamSecrets(stream secret_v3.SecretDiscoveryService_StreamSecretsServer) error { log := rpccontext.Logger(stream.Context()) selectors, err := h.c.Attestor.Attest(stream.Context()) if err != nil { log.WithError(err).Error("Failed to attest the workload") + return workloadAttestationFailedError(stream.Context()) + } + + if err := h.rateLimit(stream.Context(), MethodStreamSecrets, selectors); err != nil { return err } @@ -224,12 +257,23 @@ func (h *Handler) DeltaSecrets(secret_v3.SecretDiscoveryService_DeltaSecretsServ return status.Error(codes.Unimplemented, "Method is not implemented") } +func workloadAttestationFailedError(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + return status.Error(codes.Unavailable, "workload attestation failed") +} + func (h *Handler) FetchSecrets(ctx context.Context, req *discovery_v3.DiscoveryRequest) (*discovery_v3.DiscoveryResponse, error) { log := rpccontext.Logger(ctx).WithField(telemetry.ResourceNames, req.ResourceNames) selectors, err := h.c.Attestor.Attest(ctx) if err != nil { log.WithError(err).Error("Failed to attest the workload") + return nil, workloadAttestationFailedError(ctx) + } + + if err := h.rateLimit(ctx, MethodFetchSecrets, selectors); err != nil { return nil, err } diff --git a/pkg/agent/endpoints/sdsv3/handler_test.go b/pkg/agent/endpoints/sdsv3/handler_test.go index 115e07e7a3..027ba0c467 100644 --- a/pkg/agent/endpoints/sdsv3/handler_test.go +++ b/pkg/agent/endpoints/sdsv3/handler_test.go @@ -5,19 +5,21 @@ import ( "crypto/x509" "errors" "net" + "os" "sync" "testing" "time" + "dario.cat/mergo" core_v3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" tls_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3" discovery_v3 "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3" secret_v3 "github.com/envoyproxy/go-control-plane/envoy/service/secret/v3" envoy_type_v3 "github.com/envoyproxy/go-control-plane/envoy/type/v3" - "github.com/imdario/mergo" "github.com/sirupsen/logrus/hooks/test" "github.com/spiffe/go-spiffe/v2/bundle/spiffebundle" "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire/pkg/agent/api/rpccontext" "github.com/spiffe/spire/pkg/agent/manager/cache" "github.com/spiffe/spire/pkg/common/api/middleware" "github.com/spiffe/spire/pkg/common/peertracker" @@ -31,6 +33,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" + grpcstatus "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/structpb" ) @@ -1198,17 +1201,122 @@ func TestFetchSecrets(t *testing.T) { } } +// fakeRateLimiter allows the first N calls per method and rejects the rest. +type fakeRateLimiter struct { + mu sync.Mutex + calls map[string]int + limit int +} + +func newFakeRateLimiter(limit int) *fakeRateLimiter { + return &fakeRateLimiter{calls: make(map[string]int), limit: limit} +} + +func (r *fakeRateLimiter) RateLimit(fullMethod string, _ []*common.Selector) error { + r.mu.Lock() + defer r.mu.Unlock() + r.calls[fullMethod]++ + if r.calls[fullMethod] > r.limit { + return grpcstatus.Errorf(codes.Unavailable, "rate limit exceeded for %s", fullMethod) + } + return nil +} + +// invoked reports whether RateLimit was called for any method. +func (r *fakeRateLimiter) invoked() bool { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.calls) > 0 +} + +func TestStreamSecretsRateLimit(t *testing.T) { + rl := newFakeRateLimiter(1) + // Use a caller PID that is not the agent's own so the agent exemption does + // not apply and rate limiting is actually enforced. In production the + // peertracker middleware always sets a caller PID. + test := setupTestWithConfigAsPID(t, Config{RateLimiter: rl}, os.Getpid()+1) + defer test.cleanup() + + // First stream open is within the limit. + stream, err := test.handler.StreamSecrets(context.Background()) + require.NoError(t, err) + test.sendAndWait(stream, &discovery_v3.DiscoveryRequest{ResourceNames: []string{"default"}}) + require.NoError(t, stream.CloseSend()) + _, err = stream.Recv() + require.NoError(t, err) + + // Second stream open exhausts the limit. + stream2, err := test.handler.StreamSecrets(context.Background()) + require.NoError(t, err) + require.NoError(t, stream2.Send(&discovery_v3.DiscoveryRequest{})) + _, err = stream2.Recv() + require.Error(t, err) + spiretest.RequireGRPCStatusContains(t, err, codes.Unavailable, "rate limit exceeded") +} + +func TestFetchSecretsRateLimit(t *testing.T) { + rl := newFakeRateLimiter(1) + // Use a caller PID that is not the agent's own so the agent exemption does + // not apply and rate limiting is actually enforced. In production the + // peertracker middleware always sets a caller PID. + test := setupTestWithConfigAsPID(t, Config{RateLimiter: rl}, os.Getpid()+1) + defer test.cleanup() + + req := &discovery_v3.DiscoveryRequest{ResourceNames: []string{"default"}} + + // First call is within the limit. + resp, err := test.handler.FetchSecrets(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + + // Second call exhausts the limit. + _, err = test.handler.FetchSecrets(context.Background(), req) + require.Error(t, err) + spiretest.RequireGRPCStatusContains(t, err, codes.Unavailable, "rate limit exceeded") +} + +// TestRateLimitAgentExemption verifies that the SDS handlers skip rate limiting +// when the caller PID matches the agent's own PID, mirroring the Workload API +// behavior. A reject-all limiter with a configured limit of 0 would deny every +// call if it were consulted. +func TestRateLimitAgentExemption(t *testing.T) { + t.Run("StreamSecrets", func(t *testing.T) { + rl := newFakeRateLimiter(0) + test := setupTestWithConfigAsPID(t, Config{RateLimiter: rl}, os.Getpid()) + defer test.cleanup() + + stream, err := test.handler.StreamSecrets(context.Background()) + require.NoError(t, err) + test.sendAndWait(stream, &discovery_v3.DiscoveryRequest{ResourceNames: []string{"default"}}) + require.NoError(t, stream.CloseSend()) + _, err = stream.Recv() + require.NoError(t, err) + require.False(t, rl.invoked(), "rate limiter must not be invoked for agent-self calls") + }) + + t.Run("FetchSecrets", func(t *testing.T) { + rl := newFakeRateLimiter(0) + test := setupTestWithConfigAsPID(t, Config{RateLimiter: rl}, os.Getpid()) + defer test.cleanup() + + resp, err := test.handler.FetchSecrets(context.Background(), &discovery_v3.DiscoveryRequest{ResourceNames: []string{"default"}}) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, rl.invoked(), "rate limiter must not be invoked for agent-self calls") + }) +} + func setupTest(t *testing.T) *handlerTest { - return setupTestWithManager(t, Config{}, NewFakeManager(t)) + return setupTestWithManager(t, Config{}, NewFakeManager(t), 0) } func setupErrTest(t *testing.T) *handlerTest { manager := NewFakeManager(t) manager.err = errors.New("bad-error") - return setupTestWithManager(t, Config{}, manager) + return setupTestWithManager(t, Config{}, manager, 0) } -func setupTestWithManager(t *testing.T, c Config, manager *FakeManager) *handlerTest { +func setupTestWithManager(t *testing.T, c Config, manager *FakeManager, asPID int) *handlerTest { defaultConfig := Config{ Manager: manager, Attestor: FakeAttestor(workloadSelectors), @@ -1229,7 +1337,16 @@ func setupTestWithManager(t *testing.T, c Config, manager *FakeManager) *handler conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) require.NoError(t, err) log, _ := test.NewNullLogger() - unaryInterceptor, streamInterceptor := middleware.Interceptors(middleware.WithLogger(log)) + mw := middleware.WithLogger(log) + if asPID != 0 { + // Inject a caller PID into the request context, mirroring the + // production peertracker middleware, so the handler's isAgent check + // can be exercised. + mw = middleware.Chain(mw, middleware.Preprocess(func(ctx context.Context, _ string, _ any) (context.Context, error) { + return rpccontext.WithCallerPID(ctx, asPID), nil + })) + } + unaryInterceptor, streamInterceptor := middleware.Interceptors(mw) server := grpc.NewServer(grpc.Creds(FakeCreds{}), grpc.UnaryInterceptor(unaryInterceptor), grpc.StreamInterceptor(streamInterceptor), @@ -1252,7 +1369,12 @@ func setupTestWithManager(t *testing.T, c Config, manager *FakeManager) *handler func setupTestWithConfig(t *testing.T, c Config) *handlerTest { manager := NewFakeManager(t) - return setupTestWithManager(t, c, manager) + return setupTestWithManager(t, c, manager, 0) +} + +func setupTestWithConfigAsPID(t *testing.T, c Config, asPID int) *handlerTest { + manager := NewFakeManager(t) + return setupTestWithManager(t, c, manager, asPID) } type handlerTest struct { diff --git a/pkg/agent/endpoints/workload/handler.go b/pkg/agent/endpoints/workload/handler.go index 6b2edb8691..d67c15e01d 100644 --- a/pkg/agent/endpoints/workload/handler.go +++ b/pkg/agent/endpoints/workload/handler.go @@ -8,6 +8,8 @@ import ( "errors" "fmt" "os" + "slices" + "strings" "time" "github.com/sirupsen/logrus" @@ -16,6 +18,7 @@ import ( "github.com/spiffe/go-spiffe/v2/spiffeid" "github.com/spiffe/spire/pkg/agent/api/rpccontext" "github.com/spiffe/spire/pkg/agent/client" + "github.com/spiffe/spire/pkg/agent/common/hintsfilter" "github.com/spiffe/spire/pkg/agent/manager/cache" "github.com/spiffe/spire/pkg/common/bundleutil" "github.com/spiffe/spire/pkg/common/jwtsvid" @@ -40,11 +43,25 @@ type Attestor interface { Attest(ctx context.Context) ([]*common.Selector, error) } +// RateLimiter enforces per-selector-set rate limiting on Workload API methods. +type RateLimiter interface { + RateLimit(fullMethod string, selectors []*common.Selector) error +} + +const ( + MethodFetchX509SVID = "/SpiffeWorkloadAPI/FetchX509SVID" + MethodFetchJWTSVID = "/SpiffeWorkloadAPI/FetchJWTSVID" + MethodFetchX509Bundles = "/SpiffeWorkloadAPI/FetchX509Bundles" + MethodFetchJWTBundles = "/SpiffeWorkloadAPI/FetchJWTBundles" +) + type Config struct { Manager Manager Attestor Attestor + RateLimiter RateLimiter AllowUnauthenticatedVerifiers bool AllowedForeignJWTClaims map[string]struct{} + LogSelectors []string TrustDomain spiffeid.TrustDomain } @@ -60,6 +77,19 @@ func New(c Config) *Handler { } } +func (h *Handler) rateLimit(ctx context.Context, fullMethod string, selectors []*common.Selector) error { + if h.c.RateLimiter == nil { + return nil + } + // Exempt the agent's own calls from rate limiting so that an + // operator-configured limit can't deny the agent itself (e.g. the health + // check, which exercises the Workload API) and mark it unhealthy. + if isAgent(ctx) { + return nil + } + return h.c.RateLimiter.RateLimit(fullMethod, selectors) +} + // FetchJWTSVID processes request for a JWT-SVID. In case of multiple fetched SVIDs with same hint, the SVID that has the oldest // associated entry will be returned. func (h *Handler) FetchJWTSVID(ctx context.Context, req *workload.JWTSVIDRequest) (resp *workload.JWTSVIDResponse, err error) { @@ -80,13 +110,17 @@ func (h *Handler) FetchJWTSVID(ctx context.Context, req *workload.JWTSVIDRequest selectors, err := h.c.Attestor.Attest(ctx) if err != nil { loggerWithContextInfo(ctx, log, start, err).Error("Workload attestation failed") + return nil, workloadAttestationFailedError(ctx) + } + + if err := h.rateLimit(ctx, MethodFetchJWTSVID, selectors); err != nil { return nil, err } log = log.WithField(telemetry.Registered, true) entries := h.c.Manager.MatchingRegistrationEntries(selectors) - entries = filterRegistrations(entries, log) + entries = hintsfilter.FilterRegistrations(entries, log) resp = new(workload.JWTSVIDResponse) @@ -104,7 +138,7 @@ func (h *Handler) FetchJWTSVID(ctx context.Context, req *workload.JWTSVIDRequest } if len(resp.Svids) == 0 { - logNoIdentityIssued(ctx, log, start) + h.logNoIdentityIssued(ctx, log, selectors, start) return nil, status.Error(codes.PermissionDenied, "no identity issued") } @@ -120,6 +154,10 @@ func (h *Handler) FetchJWTBundles(_ *workload.JWTBundlesRequest, stream workload selectors, err := h.c.Attestor.Attest(ctx) if err != nil { loggerWithContextInfo(ctx, log, start, err).Error("Workload attestation failed") + return workloadAttestationFailedError(ctx) + } + + if err := h.rateLimit(ctx, MethodFetchJWTBundles, selectors); err != nil { return err } @@ -134,7 +172,7 @@ func (h *Handler) FetchJWTBundles(_ *workload.JWTBundlesRequest, stream workload for { select { case update := <-subscriber.Updates(): - if previousResp, err = sendJWTBundlesResponse(update, stream, log, h.c.AllowUnauthenticatedVerifiers, previousResp, start); err != nil { + if previousResp, err = h.sendJWTBundlesResponse(update, stream, selectors, log, previousResp, start); err != nil { return err } case <-ctx.Done(): @@ -161,7 +199,7 @@ func (h *Handler) ValidateJWTSVID(ctx context.Context, req *workload.ValidateJWT selectors, err := h.c.Attestor.Attest(ctx) if err != nil { loggerWithContextInfo(ctx, log, start, err).Error("Workload attestation failed") - return nil, err + return nil, workloadAttestationFailedError(ctx) } bundles := h.getWorkloadBundles(selectors) @@ -219,6 +257,10 @@ func (h *Handler) FetchX509SVID(_ *workload.X509SVIDRequest, stream workload.Spi selectors, err := h.c.Attestor.Attest(ctx) if err != nil { loggerWithContextInfo(ctx, log, start, err).Error("Workload attestation failed") + return workloadAttestationFailedError(ctx) + } + + if err := h.rateLimit(ctx, MethodFetchX509SVID, selectors); err != nil { return err } @@ -229,14 +271,11 @@ func (h *Handler) FetchX509SVID(_ *workload.X509SVIDRequest, stream workload.Spi } defer subscriber.Finish() - // The agent health check currently exercises the Workload API. - // Only log if it is not the agent itself. - quietLogging := isAgent(ctx) for { select { case update := <-subscriber.Updates(): - update.Identities = filterIdentities(update.Identities, log) - if err := sendX509SVIDResponse(update, stream, log, quietLogging, start); err != nil { + update.Identities = hintsfilter.FilterIdentities(update.Identities, log) + if err := h.sendX509SVIDResponse(update, stream, selectors, log, start); err != nil { return err } case <-ctx.Done(): @@ -254,6 +293,10 @@ func (h *Handler) FetchX509Bundles(_ *workload.X509BundlesRequest, stream worklo selectors, err := h.c.Attestor.Attest(ctx) if err != nil { loggerWithContextInfo(ctx, log, start, err).Error("Workload attestation failed") + return workloadAttestationFailedError(ctx) + } + + if err := h.rateLimit(ctx, MethodFetchX509Bundles, selectors); err != nil { return err } @@ -264,14 +307,11 @@ func (h *Handler) FetchX509Bundles(_ *workload.X509BundlesRequest, stream worklo } defer subscriber.Finish() - // The agent health check currently exercises the Workload API. - // Only log if it is not the agent itself. - quietLogging := isAgent(ctx) var previousResp *workload.X509BundlesResponse for { select { case update := <-subscriber.Updates(): - previousResp, err = sendX509BundlesResponse(update, stream, log, h.c.AllowUnauthenticatedVerifiers, previousResp, quietLogging, start) + previousResp, err = h.sendX509BundlesResponse(update, stream, selectors, log, previousResp, start) if err != nil { return err } @@ -281,6 +321,21 @@ func (h *Handler) FetchX509Bundles(_ *workload.X509BundlesRequest, stream worklo } } +func (h *Handler) FetchWITSVID(_ *workload.WITSVIDRequest, stream workload.SpiffeWorkloadAPI_FetchWITSVIDServer) error { + return status.Error(codes.Unimplemented, "fetching WIT-SVIDs is not implemented") +} + +func (h *Handler) FetchWITBundles(_ *workload.WITBundlesRequest, stream workload.SpiffeWorkloadAPI_FetchWITBundlesServer) error { + return status.Error(codes.Unimplemented, "fetching WIT bundles is not implemented") +} + +func workloadAttestationFailedError(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + return status.Error(codes.Unavailable, "workload attestation failed") +} + func (h *Handler) fetchJWTSVID(ctx context.Context, log logrus.FieldLogger, entry *common.RegistrationEntry, audience []string, start time.Time) (*workload.JWTSVID, error) { spiffeID, err := spiffeid.FromString(entry.SpiffeId) if err != nil { @@ -304,10 +359,14 @@ func (h *Handler) fetchJWTSVID(ctx context.Context, log logrus.FieldLogger, entr }, nil } -func sendX509BundlesResponse(update *cache.WorkloadUpdate, stream workload.SpiffeWorkloadAPI_FetchX509BundlesServer, log logrus.FieldLogger, allowUnauthenticatedVerifiers bool, previousResponse *workload.X509BundlesResponse, quietLogging bool, start time.Time) (*workload.X509BundlesResponse, error) { - if !allowUnauthenticatedVerifiers && !update.HasIdentity() { +func (h *Handler) sendX509BundlesResponse(update *cache.WorkloadUpdate, stream workload.SpiffeWorkloadAPI_FetchX509BundlesServer, selectors []*common.Selector, log logrus.FieldLogger, previousResponse *workload.X509BundlesResponse, start time.Time) (*workload.X509BundlesResponse, error) { + ctx := stream.Context() + // The agent health check currently exercises the Workload API. + // Only log if it is not the agent itself. + quietLogging := isAgent(ctx) + if !h.c.AllowUnauthenticatedVerifiers && !update.HasIdentity() { if !quietLogging { - logNoIdentityIssued(stream.Context(), log, start) + h.logNoIdentityIssued(ctx, log, selectors, start) } return nil, status.Error(codes.PermissionDenied, "no identity issued") } @@ -323,7 +382,7 @@ func sendX509BundlesResponse(update *cache.WorkloadUpdate, stream workload.Spiff } if err := stream.Send(resp); err != nil { - loggerWithContextInfo(stream.Context(), log, start, err).Error("Failed to send X509 bundle response") + loggerWithContextInfo(ctx, log, start, err).Error("Failed to send X509 bundle response") return nil, err } @@ -350,10 +409,14 @@ func composeX509BundlesResponse(update *cache.WorkloadUpdate) (*workload.X509Bun }, nil } -func sendX509SVIDResponse(update *cache.WorkloadUpdate, stream workload.SpiffeWorkloadAPI_FetchX509SVIDServer, log logrus.FieldLogger, quietLogging bool, start time.Time) (err error) { +func (h *Handler) sendX509SVIDResponse(update *cache.WorkloadUpdate, stream workload.SpiffeWorkloadAPI_FetchX509SVIDServer, selectors []*common.Selector, log logrus.FieldLogger, start time.Time) (err error) { + ctx := stream.Context() + // The agent health check currently exercises the Workload API. + // Only log if it is not the agent itself. + quietLogging := isAgent(ctx) if len(update.Identities) == 0 { if !quietLogging { - logNoIdentityIssued(stream.Context(), log, start) + h.logNoIdentityIssued(ctx, log, selectors, start) } return status.Error(codes.PermissionDenied, "no identity issued") } @@ -367,7 +430,7 @@ func sendX509SVIDResponse(update *cache.WorkloadUpdate, stream workload.SpiffeWo } if err := stream.Send(resp); err != nil { - loggerWithContextInfo(stream.Context(), log, start, err).Error("Failed to send X.509 SVID response") + loggerWithContextInfo(ctx, log, start, err).Error("Failed to send X.509 SVID response") return err } @@ -422,9 +485,10 @@ func composeX509SVIDResponse(update *cache.WorkloadUpdate) (*workload.X509SVIDRe return resp, nil } -func sendJWTBundlesResponse(update *cache.WorkloadUpdate, stream workload.SpiffeWorkloadAPI_FetchJWTBundlesServer, log logrus.FieldLogger, allowUnauthenticatedVerifiers bool, previousResponse *workload.JWTBundlesResponse, start time.Time) (*workload.JWTBundlesResponse, error) { - if !allowUnauthenticatedVerifiers && !update.HasIdentity() { - logNoIdentityIssued(stream.Context(), log, start) +func (h *Handler) sendJWTBundlesResponse(update *cache.WorkloadUpdate, stream workload.SpiffeWorkloadAPI_FetchJWTBundlesServer, selectors []*common.Selector, log logrus.FieldLogger, previousResponse *workload.JWTBundlesResponse, start time.Time) (*workload.JWTBundlesResponse, error) { + ctx := stream.Context() + if !h.c.AllowUnauthenticatedVerifiers && !update.HasIdentity() { + h.logNoIdentityIssued(ctx, log, selectors, start) return nil, status.Error(codes.PermissionDenied, "no identity issued") } @@ -439,7 +503,7 @@ func sendJWTBundlesResponse(update *cache.WorkloadUpdate, stream workload.Spiffe } if err := stream.Send(resp); err != nil { - loggerWithContextInfo(stream.Context(), log, start, err).Error("Failed to send JWT bundle response") + loggerWithContextInfo(ctx, log, start, err).Error("Failed to send JWT bundle response") return nil, err } @@ -502,8 +566,62 @@ func loggerWithContextInfo(ctx context.Context, log logrus.FieldLogger, start ti return log } -func logNoIdentityIssued(ctx context.Context, log logrus.FieldLogger, start time.Time) { - loggerWithContextInfo(ctx, log.WithField(telemetry.Registered, false), start, nil).Error("No identity issued") +func (h *Handler) logNoIdentityIssued(ctx context.Context, log logrus.FieldLogger, selectors []*common.Selector, start time.Time) { + fields := logrus.Fields{ + telemetry.Registered: false, + } + if loggableSelectors := filterLoggableSelectors(selectors, h.c.LogSelectors); len(loggableSelectors) > 0 { + fields[telemetry.Selectors] = selectorFieldFromSelectors(loggableSelectors) + } + loggerWithContextInfo(ctx, log.WithFields(fields), start, nil).Error("No identity issued") +} + +func selectorFieldFromSelectors(selectors []*common.Selector) string { + selectorFields := make([]string, 0, len(selectors)) + for _, selector := range selectors { + selectorFields = append(selectorFields, renderSelector(selector)) + } + slices.Sort(selectorFields) + return strings.Join(selectorFields, ",") +} + +func filterLoggableSelectors(selectors []*common.Selector, logSelectorPrefixes []string) []*common.Selector { + if len(logSelectorPrefixes) == 0 { + return nil + } + + var loggableSelectors []*common.Selector + for _, selector := range selectors { + if isLoggableSelector(selector, logSelectorPrefixes) { + loggableSelectors = append(loggableSelectors, selector) + } + } + return loggableSelectors +} + +func isLoggableSelector(selector *common.Selector, logSelectorPrefixes []string) bool { + if selector == nil { + return false + } + + selectorValue := renderSelector(selector) + + // Workload attestor subtypes are encoded in the selector value, e.g. + // Type="k8s", Value="ns:default", so match against the rendered selector. + for _, prefix := range logSelectorPrefixes { + if selectorValue == prefix || strings.HasPrefix(selectorValue, prefix+":") { + return true + } + } + return false +} + +func renderSelector(selector *common.Selector) string { + selectorValue := selector.Type + if selector.Value != "" { + selectorValue += ":" + selector.Value + } + return selectorValue } func (h *Handler) getWorkloadBundles(selectors []*common.Selector) (bundles []*spiffebundle.Bundle) { @@ -561,80 +679,3 @@ func isClaimAllowed(claim string, allowedClaims map[string]struct{}) bool { return ok } } - -func filterIdentities(identities []cache.Identity, log logrus.FieldLogger) []cache.Identity { - var filteredIdentities []cache.Identity - var entries []*common.RegistrationEntry - for _, identity := range identities { - entries = append(entries, identity.Entry) - } - - entriesToRemove := getEntriesToRemove(entries, log) - - for _, identity := range identities { - if _, ok := entriesToRemove[identity.Entry.EntryId]; !ok { - filteredIdentities = append(filteredIdentities, identity) - } - } - - return filteredIdentities -} - -func filterRegistrations(entries []*common.RegistrationEntry, log logrus.FieldLogger) []*common.RegistrationEntry { - var filteredEntries []*common.RegistrationEntry - entriesToRemove := getEntriesToRemove(entries, log) - - for _, entry := range entries { - if _, ok := entriesToRemove[entry.EntryId]; !ok { - filteredEntries = append(filteredEntries, entry) - } - } - - return filteredEntries -} - -func getEntriesToRemove(entries []*common.RegistrationEntry, log logrus.FieldLogger) map[string]struct{} { - entriesToRemove := make(map[string]struct{}) - hintsMap := make(map[string]*common.RegistrationEntry) - - for _, entry := range entries { - if entry.Hint == "" { - continue - } - if entryWithNonUniqueHint, ok := hintsMap[entry.Hint]; ok { - entryToMaintain, entryToRemove := hintTieBreaking(entry, entryWithNonUniqueHint) - - hintsMap[entry.Hint] = entryToMaintain - entriesToRemove[entryToRemove.EntryId] = struct{}{} - - log.WithFields(logrus.Fields{ - telemetry.Hint: entryToRemove.Hint, - telemetry.RegistrationID: entryToRemove.EntryId, - }).Warn("Ignoring entry with duplicate hint") - } else { - hintsMap[entry.Hint] = entry - } - } - - return entriesToRemove -} - -func hintTieBreaking(entryA *common.RegistrationEntry, entryB *common.RegistrationEntry) (maintain *common.RegistrationEntry, remove *common.RegistrationEntry) { - switch { - case entryA.CreatedAt < entryB.CreatedAt: - maintain = entryA - remove = entryB - case entryA.CreatedAt > entryB.CreatedAt: - maintain = entryB - remove = entryA - default: - if entryA.EntryId < entryB.EntryId { - maintain = entryA - remove = entryB - } else { - maintain = entryB - remove = entryA - } - } - return -} diff --git a/pkg/agent/endpoints/workload/handler_test.go b/pkg/agent/endpoints/workload/handler_test.go index 6b7d542643..e6e600d28a 100644 --- a/pkg/agent/endpoints/workload/handler_test.go +++ b/pkg/agent/endpoints/workload/handler_test.go @@ -34,6 +34,7 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/structpb" ) @@ -42,6 +43,19 @@ var ( td2 = spiffeid.RequireTrustDomainFromString("domain2.test") workloadID = spiffeid.RequireFromPath(td, "/workload") + + k8sNamespaceSelector = &common.Selector{ + Type: "k8s", + Value: "ns:default", + } + k8sPodLabelSelector = &common.Selector{ + Type: "k8s", + Value: "pod-label:secret:value", + } + unixUserSelector = &common.Selector{ + Type: "unix", + Value: "user:root", + } ) func TestFetchX509SVID(t *testing.T) { @@ -75,15 +89,17 @@ func TestFetchX509SVID(t *testing.T) { identities[4].Entry.CreatedAt = now + 7200 for _, tt := range []struct { - name string - updates []*cache.WorkloadUpdate - attestErr error - managerErr error - asPID int - expectCode codes.Code - expectMsg string - expectResp *workloadPB.X509SVIDResponse - expectLogs []spiretest.LogEntry + name string + updates []*cache.WorkloadUpdate + selectors []*common.Selector + logSelectors []string + attestErr error + managerErr error + asPID int + expectCode codes.Code + expectMsg string + expectResp *workloadPB.X509SVIDResponse + expectLogs []spiretest.LogEntry }{ { name: "no identity issued", @@ -102,6 +118,45 @@ func TestFetchX509SVID(t *testing.T) { }, }, }, + { + name: "no identity issued with loggable selectors", + updates: []*cache.WorkloadUpdate{{}}, + selectors: []*common.Selector{unixUserSelector, k8sPodLabelSelector, k8sNamespaceSelector}, + logSelectors: []string{"k8s:ns", "unix:user"}, + expectCode: codes.PermissionDenied, + expectMsg: "no identity issued", + expectLogs: []spiretest.LogEntry{ + { + Level: logrus.ErrorLevel, + Message: "No identity issued", + Data: logrus.Fields{ + "registered": "false", + "selectors": "k8s:ns:default,unix:user:root", + "service": "WorkloadAPI", + "method": "FetchX509SVID", + }, + }, + }, + }, + { + name: "no identity issued with selectors but no allowlist match", + updates: []*cache.WorkloadUpdate{{}}, + selectors: []*common.Selector{k8sNamespaceSelector}, + logSelectors: []string{"k8s:n"}, + expectCode: codes.PermissionDenied, + expectMsg: "no identity issued", + expectLogs: []spiretest.LogEntry{ + { + Level: logrus.ErrorLevel, + Message: "No identity issued", + Data: logrus.Fields{ + "registered": "false", + "service": "WorkloadAPI", + "method": "FetchX509SVID", + }, + }, + }, + }, { name: "no identity issued (healthcheck)", updates: []*cache.WorkloadUpdate{{}}, @@ -112,8 +167,8 @@ func TestFetchX509SVID(t *testing.T) { { name: "attest error", attestErr: errors.New("ohno"), - expectCode: codes.Unknown, - expectMsg: "ohno", + expectCode: codes.Unavailable, + expectMsg: "workload attestation failed", expectLogs: []spiretest.LogEntry{ { Level: logrus.ErrorLevel, @@ -268,12 +323,14 @@ func TestFetchX509SVID(t *testing.T) { } { t.Run(tt.name, func(t *testing.T) { params := testParams{ - CA: ca, - Updates: tt.updates, - AttestErr: tt.attestErr, - ExpectLogs: tt.expectLogs, - AsPID: tt.asPID, - ManagerErr: tt.managerErr, + CA: ca, + Updates: tt.updates, + Selectors: tt.selectors, + LogSelectors: tt.logSelectors, + AttestErr: tt.attestErr, + ExpectLogs: tt.expectLogs, + AsPID: tt.asPID, + ManagerErr: tt.managerErr, } runTest(t, params, func(ctx context.Context, client workloadPB.SpiffeWorkloadAPIClient) { @@ -301,6 +358,8 @@ func TestFetchX509Bundles(t *testing.T) { for _, tt := range []struct { testName string updates []*cache.WorkloadUpdate + selectors []*common.Selector + logSelectors []string attestErr error managerErr error expectCode codes.Code @@ -326,11 +385,31 @@ func TestFetchX509Bundles(t *testing.T) { }, }, }, + { + testName: "no identity issued with loggable selectors", + updates: []*cache.WorkloadUpdate{{}}, + selectors: []*common.Selector{k8sNamespaceSelector, k8sPodLabelSelector}, + logSelectors: []string{"k8s:ns"}, + expectCode: codes.PermissionDenied, + expectMsg: "no identity issued", + expectLogs: []spiretest.LogEntry{ + { + Level: logrus.ErrorLevel, + Message: "No identity issued", + Data: logrus.Fields{ + "registered": "false", + "selectors": "k8s:ns:default", + "service": "WorkloadAPI", + "method": "FetchX509Bundles", + }, + }, + }, + }, { testName: "attest error", attestErr: errors.New("ohno"), - expectCode: codes.Unknown, - expectMsg: "ohno", + expectCode: codes.Unavailable, + expectMsg: "workload attestation failed", expectLogs: []spiretest.LogEntry{ { Level: logrus.ErrorLevel, @@ -428,6 +507,8 @@ func TestFetchX509Bundles(t *testing.T) { params := testParams{ CA: ca, Updates: tt.updates, + Selectors: tt.selectors, + LogSelectors: tt.logSelectors, AttestErr: tt.attestErr, ExpectLogs: tt.expectLogs, AllowUnauthenticatedVerifiers: tt.allowUnauthenticatedVerifiers, @@ -618,6 +699,8 @@ func TestFetchJWTSVID(t *testing.T) { identities []cache.Identity spiffeID string audience []string + selectors []*common.Selector + logSelectors []string attestErr error managerErr error expectCode codes.Code @@ -676,6 +759,26 @@ func TestFetchJWTSVID(t *testing.T) { }, }, }, + { + name: "no identity issued with loggable selectors", + audience: []string{"AUDIENCE"}, + selectors: []*common.Selector{k8sNamespaceSelector, k8sPodLabelSelector}, + logSelectors: []string{"k8s:ns"}, + expectCode: codes.PermissionDenied, + expectMsg: "no identity issued", + expectLogs: []spiretest.LogEntry{ + { + Level: logrus.ErrorLevel, + Message: "No identity issued", + Data: logrus.Fields{ + "registered": "false", + "selectors": "k8s:ns:default", + "service": "WorkloadAPI", + "method": "FetchJWTSVID", + }, + }, + }, + }, { name: "identity found but unexpected SPIFFE ID", identities: []cache.Identity{ @@ -702,8 +805,8 @@ func TestFetchJWTSVID(t *testing.T) { name: "attest error", audience: []string{"AUDIENCE"}, attestErr: errors.New("ohno"), - expectCode: codes.Unknown, - expectMsg: "ohno", + expectCode: codes.Unavailable, + expectMsg: "workload attestation failed", expectLogs: []spiretest.LogEntry{ { Level: logrus.ErrorLevel, @@ -837,11 +940,13 @@ func TestFetchJWTSVID(t *testing.T) { } { t.Run(tt.name, func(t *testing.T) { params := testParams{ - CA: ca, - Identities: tt.identities, - AttestErr: tt.attestErr, - ManagerErr: tt.managerErr, - ExpectLogs: tt.expectLogs, + CA: ca, + Identities: tt.identities, + Selectors: tt.selectors, + LogSelectors: tt.logSelectors, + AttestErr: tt.attestErr, + ManagerErr: tt.managerErr, + ExpectLogs: tt.expectLogs, } runTest(t, params, func(ctx context.Context, client workloadPB.SpiffeWorkloadAPIClient) { @@ -868,6 +973,170 @@ func TestFetchJWTSVID(t *testing.T) { } } +// rejectAllRateLimiter counts invocations and rejects every call. Used to +// verify that the handler's isAgent(ctx) guard short-circuits before reaching +// the limiter. +type rejectAllRateLimiter struct { + called atomic.Int32 +} + +func (r *rejectAllRateLimiter) RateLimit(string, []*common.Selector) error { + r.called.Add(1) + return status.Errorf(codes.Unavailable, "rate limit exceeded") +} + +// TestRateLimitAgentExemption verifies that the rate-limited workload handlers +// skip rate limiting when the caller PID matches the agent's own PID. Without +// this exemption, an operator-configured low rate limit could deny the agent's +// own health probe (which exercises the Workload API) and mark the agent +// unhealthy. Every rate-limited method is exercised so the guard can't regress +// on one of them. +func TestRateLimitAgentExemption(t *testing.T) { + ca := testca.New(t, td) + x509SVID := ca.CreateX509SVID(workloadID) + identities := []cache.Identity{identityFromX509SVID(x509SVID, "id0")} + updates := []*cache.WorkloadUpdate{ + { + Identities: identities, + Bundle: ca.Bundle(), + }, + } + + for _, tt := range []struct { + name string + invoke func(t *testing.T, ctx context.Context, c workloadPB.SpiffeWorkloadAPIClient) + }{ + { + name: "FetchX509SVID", + invoke: func(t *testing.T, ctx context.Context, c workloadPB.SpiffeWorkloadAPIClient) { + stream, err := c.FetchX509SVID(ctx, &workloadPB.X509SVIDRequest{}) + require.NoError(t, err) + resp, err := stream.Recv() + require.NoError(t, err) + require.NotNil(t, resp) + }, + }, + { + name: "FetchJWTSVID", + invoke: func(t *testing.T, ctx context.Context, c workloadPB.SpiffeWorkloadAPIClient) { + resp, err := c.FetchJWTSVID(ctx, &workloadPB.JWTSVIDRequest{ + Audience: []string{"AUDIENCE"}, + }) + require.NoError(t, err) + require.NotNil(t, resp) + }, + }, + { + name: "FetchX509Bundles", + invoke: func(t *testing.T, ctx context.Context, c workloadPB.SpiffeWorkloadAPIClient) { + stream, err := c.FetchX509Bundles(ctx, &workloadPB.X509BundlesRequest{}) + require.NoError(t, err) + resp, err := stream.Recv() + require.NoError(t, err) + require.NotNil(t, resp) + }, + }, + { + name: "FetchJWTBundles", + invoke: func(t *testing.T, ctx context.Context, c workloadPB.SpiffeWorkloadAPIClient) { + stream, err := c.FetchJWTBundles(ctx, &workloadPB.JWTBundlesRequest{}) + require.NoError(t, err) + resp, err := stream.Recv() + require.NoError(t, err) + require.NotNil(t, resp) + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + rl := &rejectAllRateLimiter{} + params := testParams{ + CA: ca, + Identities: identities, + Updates: updates, + AsPID: os.Getpid(), + RateLimiter: rl, + } + runTest(t, params, func(ctx context.Context, c workloadPB.SpiffeWorkloadAPIClient) { + tt.invoke(t, ctx, c) + }) + assert.Zero(t, rl.called.Load(), "rate limiter must not be invoked for agent-self calls") + }) + } +} + +// TestRateLimitEnforced is the counterpart to TestRateLimitAgentExemption: it +// verifies that for a non-agent caller every rate-limited method consults the +// limiter and surfaces its rejection. This covers the branch where the caller +// is not the agent, ensuring each handler passes the request context through to +// the limiter. +func TestRateLimitEnforced(t *testing.T) { + ca := testca.New(t, td) + x509SVID := ca.CreateX509SVID(workloadID) + identities := []cache.Identity{identityFromX509SVID(x509SVID, "id0")} + updates := []*cache.WorkloadUpdate{ + { + Identities: identities, + Bundle: ca.Bundle(), + }, + } + + for _, tt := range []struct { + name string + invoke func(t *testing.T, ctx context.Context, c workloadPB.SpiffeWorkloadAPIClient) error + }{ + { + name: "FetchX509SVID", + invoke: func(t *testing.T, ctx context.Context, c workloadPB.SpiffeWorkloadAPIClient) error { + stream, err := c.FetchX509SVID(ctx, &workloadPB.X509SVIDRequest{}) + require.NoError(t, err) + _, err = stream.Recv() + return err + }, + }, + { + name: "FetchJWTSVID", + invoke: func(t *testing.T, ctx context.Context, c workloadPB.SpiffeWorkloadAPIClient) error { + _, err := c.FetchJWTSVID(ctx, &workloadPB.JWTSVIDRequest{Audience: []string{"AUDIENCE"}}) + return err + }, + }, + { + name: "FetchX509Bundles", + invoke: func(t *testing.T, ctx context.Context, c workloadPB.SpiffeWorkloadAPIClient) error { + stream, err := c.FetchX509Bundles(ctx, &workloadPB.X509BundlesRequest{}) + require.NoError(t, err) + _, err = stream.Recv() + return err + }, + }, + { + name: "FetchJWTBundles", + invoke: func(t *testing.T, ctx context.Context, c workloadPB.SpiffeWorkloadAPIClient) error { + stream, err := c.FetchJWTBundles(ctx, &workloadPB.JWTBundlesRequest{}) + require.NoError(t, err) + _, err = stream.Recv() + return err + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + rl := &rejectAllRateLimiter{} + params := testParams{ + CA: ca, + Identities: identities, + Updates: updates, + AsPID: os.Getpid() + 1, + RateLimiter: rl, + } + runTest(t, params, func(ctx context.Context, c workloadPB.SpiffeWorkloadAPIClient) { + err := tt.invoke(t, ctx, c) + spiretest.RequireGRPCStatus(t, err, codes.Unavailable, "rate limit exceeded") + }) + assert.NotZero(t, rl.called.Load(), "rate limiter must be invoked for non-agent calls") + }) + } +} + func TestFetchJWTBundles(t *testing.T) { td := spiffeid.RequireTrustDomainFromString("domain.test") ca := testca.New(t, td) @@ -895,6 +1164,8 @@ func TestFetchJWTBundles(t *testing.T) { for _, tt := range []struct { name string updates []*cache.WorkloadUpdate + selectors []*common.Selector + logSelectors []string attestErr error managerErr error expectCode codes.Code @@ -920,11 +1191,31 @@ func TestFetchJWTBundles(t *testing.T) { }, }, }, + { + name: "no identity issued with loggable selectors", + updates: []*cache.WorkloadUpdate{{}}, + selectors: []*common.Selector{k8sNamespaceSelector, k8sPodLabelSelector}, + logSelectors: []string{"k8s:ns"}, + expectCode: codes.PermissionDenied, + expectMsg: "no identity issued", + expectLogs: []spiretest.LogEntry{ + { + Level: logrus.ErrorLevel, + Message: "No identity issued", + Data: logrus.Fields{ + "registered": "false", + "selectors": "k8s:ns:default", + "service": "WorkloadAPI", + "method": "FetchJWTBundles", + }, + }, + }, + }, { name: "attest error", attestErr: errors.New("ohno"), - expectCode: codes.Unknown, - expectMsg: "ohno", + expectCode: codes.Unavailable, + expectMsg: "workload attestation failed", expectLogs: []spiretest.LogEntry{ { Level: logrus.ErrorLevel, @@ -1043,6 +1334,8 @@ func TestFetchJWTBundles(t *testing.T) { params := testParams{ CA: ca, Updates: tt.updates, + Selectors: tt.selectors, + LogSelectors: tt.logSelectors, AttestErr: tt.attestErr, ExpectLogs: tt.expectLogs, AllowUnauthenticatedVerifiers: tt.allowUnauthenticatedVerifiers, @@ -1304,8 +1597,8 @@ func TestValidateJWTSVID(t *testing.T) { svid: "BAD", audience: "AUDIENCE", attestErr: errors.New("ohno"), - expectCode: codes.Unknown, - expectMsg: "ohno", + expectCode: codes.Unavailable, + expectMsg: "workload attestation failed", expectLogs: []spiretest.LogEntry{ { Level: logrus.ErrorLevel, @@ -1500,12 +1793,15 @@ type testParams struct { CA *testca.CA Identities []cache.Identity Updates []*cache.WorkloadUpdate + Selectors []*common.Selector + LogSelectors []string AttestErr error ManagerErr error ExpectLogs []spiretest.LogEntry AsPID int AllowUnauthenticatedVerifiers bool AllowedForeignJWTClaims map[string]struct{} + RateLimiter workload.RateLimiter } func runTest(t *testing.T, params testParams, fn func(ctx context.Context, client workloadPB.SpiffeWorkloadAPIClient)) { @@ -1521,9 +1817,11 @@ func runTest(t *testing.T, params testParams, fn func(ctx context.Context, clien handler := workload.New(workload.Config{ TrustDomain: td, Manager: manager, - Attestor: &FakeAttestor{err: params.AttestErr}, + Attestor: &FakeAttestor{selectors: params.Selectors, err: params.AttestErr}, AllowUnauthenticatedVerifiers: params.AllowUnauthenticatedVerifiers, AllowedForeignJWTClaims: params.AllowedForeignJWTClaims, + RateLimiter: params.RateLimiter, + LogSelectors: params.LogSelectors, }) server := grpctest.StartServer(t, func(s grpc.ServiceRegistrar) { diff --git a/pkg/agent/manager/cache/bundle_cache.go b/pkg/agent/manager/cache/bundle_cache.go index 1f275ac630..0cd757a696 100644 --- a/pkg/agent/manager/cache/bundle_cache.go +++ b/pkg/agent/manager/cache/bundle_cache.go @@ -1,15 +1,19 @@ package cache import ( + "fmt" "maps" - "github.com/imkira/go-observer" "github.com/spiffe/go-spiffe/v2/bundle/spiffebundle" + "github.com/spiffe/go-spiffe/v2/bundle/x509bundle" "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire/pkg/common/observer" ) type Bundle = spiffebundle.Bundle +var _ x509bundle.Source = (*BundleCache)(nil) + type BundleCache struct { trustDomain spiffeid.TrustDomain bundles observer.Property @@ -43,6 +47,15 @@ func (c *BundleCache) SubscribeToBundleChanges() *BundleStream { return NewBundleStream(c.bundles.Observe()) } +func (c *BundleCache) GetX509BundleForTrustDomain(trustDomain spiffeid.TrustDomain) (*x509bundle.Bundle, error) { + bundles := c.Bundles() + bundle, ok := bundles[trustDomain] + if !ok { + return nil, fmt.Errorf("bundle for trust domain %q not found", trustDomain) + } + return bundle.X509Bundle(), nil +} + // Wraps an observer stream to provide a type safe interface type BundleStream struct { stream observer.Stream diff --git a/pkg/agent/manager/cache/lru_cache.go b/pkg/agent/manager/cache/lru_cache.go index b2f879922f..d42f4f27b9 100644 --- a/pkg/agent/manager/cache/lru_cache.go +++ b/pkg/agent/manager/cache/lru_cache.go @@ -11,6 +11,7 @@ import ( "github.com/andres-erbsen/clock" "github.com/sirupsen/logrus" "github.com/spiffe/go-spiffe/v2/bundle/spiffebundle" + "github.com/spiffe/go-spiffe/v2/bundle/x509bundle" "github.com/spiffe/go-spiffe/v2/spiffeid" "github.com/spiffe/spire/pkg/common/backoff" "github.com/spiffe/spire/pkg/common/telemetry" @@ -585,6 +586,13 @@ func (c *LRUCache) SyncSVIDsWithSubscribers() { c.syncSVIDsWithSubscribers() } +func (c *LRUCache) X509Bundle() x509bundle.Source { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.BundleCache +} + // scheduleRotation processes SVID entries in batches, removing those tainted by X.509 authorities. // The process continues at regular intervals until all entries have been processed or the context is cancelled. func (c *LRUCache) scheduleRotation(ctx context.Context, entryIDs []string, taintedX509Authorities []*x509.Certificate) { diff --git a/pkg/agent/manager/manager.go b/pkg/agent/manager/manager.go index 9fef306418..5f79ab2c2c 100644 --- a/pkg/agent/manager/manager.go +++ b/pkg/agent/manager/manager.go @@ -9,8 +9,8 @@ import ( "time" "github.com/andres-erbsen/clock" - observer "github.com/imkira/go-observer" "github.com/spiffe/go-spiffe/v2/bundle/spiffebundle" + "github.com/spiffe/go-spiffe/v2/bundle/x509bundle" "github.com/spiffe/go-spiffe/v2/spiffeid" "github.com/spiffe/spire/pkg/agent/client" "github.com/spiffe/spire/pkg/agent/manager/cache" @@ -20,6 +20,7 @@ import ( "github.com/spiffe/spire/pkg/common/backoff" "github.com/spiffe/spire/pkg/common/errorutil" "github.com/spiffe/spire/pkg/common/nodeutil" + "github.com/spiffe/spire/pkg/common/observer" "github.com/spiffe/spire/pkg/common/rotationutil" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/common/util" @@ -94,6 +95,12 @@ type Manager interface { // GetBundle get latest cached bundle GetBundle() *cache.Bundle + + // GetBundles gets the latest cached bundles for all trust domains. + GetBundles() map[spiffeid.TrustDomain]*cache.Bundle + + // GetX509Bundle returns an X509 bundle source + GetX509Bundle() x509bundle.Source } // Cache stores each registration entry, signed X509-SVIDs for those entries, @@ -104,6 +111,9 @@ type Cache interface { // Bundle gets latest cached bundle Bundle() *spiffebundle.Bundle + // Bundles gets the latest cached bundles for all trust domains. + Bundles() map[spiffeid.TrustDomain]*spiffebundle.Bundle + // SyncSVIDsWithSubscribers syncs SVID cache SyncSVIDsWithSubscribers() @@ -136,6 +146,8 @@ type Cache interface { // Identities get all identities in cache Identities() []cache.Identity + + X509Bundle() x509bundle.Source } type manager struct { @@ -365,9 +377,9 @@ func (m *manager) runSynchronizer(ctx context.Context) error { } seconds := time.Since(startTime) if seconds < m.c.RebootstrapDelay { - fmt.Printf("Trust Bandle and Server dont agree.... Ignoring for now. Rebootstrap timeout left: %s\n", m.c.RebootstrapDelay-seconds) + m.c.Log.WithField("time_left", m.c.RebootstrapDelay-seconds).Info("Trust Bundle and Server don't agree, ignoring for now") } else { - fmt.Printf("Trust Bandle and Server dont agree.... rebootstrapping") + m.c.Log.Warn("Trust Bundle and Server don't agree, rebootstrapping") err = m.c.TrustBundleSources.SetForceRebootstrap() if err != nil { return err @@ -440,6 +452,20 @@ func (m *manager) GetBundle() *cache.Bundle { return m.cache.Bundle() } +func (m *manager) GetBundles() map[spiffeid.TrustDomain]*cache.Bundle { + m.mtx.RLock() + defer m.mtx.RUnlock() + + return m.cache.Bundles() +} + +func (m *manager) GetX509Bundle() x509bundle.Source { + m.mtx.RLock() + defer m.mtx.RUnlock() + + return m.cache.X509Bundle() +} + func (m *manager) runSVIDObserver(ctx context.Context) error { svidStream := m.SubscribeToSVIDChanges() for { diff --git a/pkg/agent/manager/manager_test.go b/pkg/agent/manager/manager_test.go index 59f270d810..1a2addf912 100644 --- a/pkg/agent/manager/manager_test.go +++ b/pkg/agent/manager/manager_test.go @@ -31,6 +31,7 @@ import ( "github.com/spiffe/spire/pkg/agent/storage" "github.com/spiffe/spire/pkg/agent/trustbundlesources" "github.com/spiffe/spire/pkg/agent/workloadkey" + commonapi "github.com/spiffe/spire/pkg/common/api" "github.com/spiffe/spire/pkg/common/bundleutil" "github.com/spiffe/spire/pkg/common/idutil" "github.com/spiffe/spire/pkg/common/rotationutil" @@ -2075,7 +2076,7 @@ func (h *mockAPI) BatchNewX509SVID(_ context.Context, req *svidv1.BatchNewX509SV entry, ok := entries[param.EntryId] if !ok { resp.Results = append(resp.Results, &svidv1.BatchNewX509SVIDResponse_Result{ - Status: api.CreateStatusf(codes.NotFound, "entry %q not found", param.EntryId), + Status: commonapi.CreateStatusf(codes.NotFound, "entry %q not found", param.EntryId), }) continue } @@ -2085,7 +2086,7 @@ func (h *mockAPI) BatchNewX509SVID(_ context.Context, req *svidv1.BatchNewX509SV h.lastestSVIDs[entry.EntryId] = svid resp.Results = append(resp.Results, &svidv1.BatchNewX509SVIDResponse_Result{ - Status: api.OK(), + Status: commonapi.OK(), Svid: &types.X509SVID{ CertChain: x509util.RawCertsFromCertificates(svid), ExpiresAt: svid[0].NotAfter.Unix(), diff --git a/pkg/agent/plugin/workloadattestor/docker/docker.go b/pkg/agent/plugin/workloadattestor/docker/docker.go index 404627eaf6..df5f861c9a 100644 --- a/pkg/agent/plugin/workloadattestor/docker/docker.go +++ b/pkg/agent/plugin/workloadattestor/docker/docker.go @@ -6,12 +6,11 @@ import ( "strings" "sync" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/image" - dockerclient "github.com/docker/docker/client" "github.com/hashicorp/go-hclog" "github.com/hashicorp/hcl" "github.com/hashicorp/hcl/hcl/token" + "github.com/moby/moby/api/types/container" + dockerclient "github.com/moby/moby/client" workloadattestorv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/agent/workloadattestor/v1" configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" "github.com/spiffe/spire/pkg/agent/common/sigstore" @@ -43,8 +42,8 @@ func builtin(p *Plugin) catalog.BuiltIn { // Docker is a subset of the docker client functionality, useful for mocking. type Docker interface { - ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error) - ImageInspectWithRaw(ctx context.Context, imageID string) (image.InspectResponse, []byte, error) + ContainerInspect(ctx context.Context, containerID string, options dockerclient.ContainerInspectOptions) (dockerclient.ContainerInspectResult, error) + ImageInspect(ctx context.Context, imageID string, inspectOpts ...dockerclient.ImageInspectOption) (dockerclient.ImageInspectResult, error) } type podmanDocker interface { @@ -74,9 +73,8 @@ func New() *Plugin { } func defaultPodmanClientFactory(socketPath string) (podmanDocker, error) { - return dockerclient.NewClientWithOpts( + return dockerclient.New( dockerclient.WithHost(socketPath), - dockerclient.WithAPIVersionNegotiation(), ) } @@ -112,10 +110,8 @@ func (p *Plugin) buildConfig(coreConfig catalog.CoreConfig, hclText string, stat if dockerHost != "" { newConfig.dockerOpts = append(newConfig.dockerOpts, dockerclient.WithHost(dockerHost)) } - if newConfig.DockerVersion == "" { - newConfig.dockerOpts = append(newConfig.dockerOpts, dockerclient.WithAPIVersionNegotiation()) - } else { - newConfig.dockerOpts = append(newConfig.dockerOpts, dockerclient.WithVersion(newConfig.DockerVersion)) + if newConfig.DockerVersion != "" { + newConfig.dockerOpts = append(newConfig.dockerOpts, dockerclient.WithAPIVersion(newConfig.DockerVersion)) } if newConfig.Sigstore != nil { @@ -156,22 +152,22 @@ func (p *Plugin) Attest(ctx context.Context, req *workloadattestorv1.AttestReque client = podmanClient } - var container container.InspectResponse + var containerResult dockerclient.ContainerInspectResult err = p.retryer.Retry(ctx, func() error { - container, err = client.ContainerInspect(ctx, containerID) + containerResult, err = client.ContainerInspect(ctx, containerID, dockerclient.ContainerInspectOptions{}) return err }) if err != nil { return nil, err } - selectors := getSelectorValuesFromConfig(container.Config) + selectors := getSelectorValuesFromConfig(containerResult.Container.Config) - var imageJSON image.InspectResponse + var imageJSON dockerclient.ImageInspectResult var inspectErr error - imageName := container.Config.Image + imageName := containerResult.Container.Config.Image if imageName != "" || p.sigstoreVerifier != nil { - imageJSON, _, inspectErr = client.ImageInspectWithRaw(ctx, imageName) + imageJSON, inspectErr = client.ImageInspect(ctx, imageName) } // Add image_config_digest selector @@ -215,6 +211,13 @@ func (p *Plugin) Attest(ctx context.Context, req *workloadattestorv1.AttestReque }, nil } +// AttestReference returns Unimplemented. This plugin does not handle +// reference-based workload attestation; the host falls back to PID-based +// Attest when the reference is a WorkloadPIDReference. +func (p *Plugin) AttestReference(_ context.Context, _ *workloadattestorv1.AttestReferenceRequest) (*workloadattestorv1.AttestReferenceResponse, error) { + return nil, status.Error(codes.Unimplemented, "AttestReference not implemented") +} + func getSelectorValuesFromConfig(cfg *container.Config) []string { var selectorValues []string for label, value := range cfg.Labels { @@ -235,7 +238,7 @@ func (p *Plugin) Configure(ctx context.Context, req *configv1.ConfigureRequest) return nil, err } - docker, err := dockerclient.NewClientWithOpts(newConfig.dockerOpts...) + docker, err := dockerclient.New(newConfig.dockerOpts...) if err != nil { return nil, err } @@ -245,6 +248,7 @@ func (p *Plugin) Configure(ctx context.Context, req *configv1.ConfigureRequest) verifier := sigstore.NewVerifier(newConfig.sigstoreConfig) err = verifier.Init(ctx) if err != nil { + _ = docker.Close() return nil, status.Errorf(codes.InvalidArgument, "error initializing sigstore verifier: %v", err) } sigstoreVerifier = verifier diff --git a/pkg/agent/plugin/workloadattestor/docker/docker_posix.go b/pkg/agent/plugin/workloadattestor/docker/docker_posix.go index 8f2c0551c1..b56cdb5dc8 100644 --- a/pkg/agent/plugin/workloadattestor/docker/docker_posix.go +++ b/pkg/agent/plugin/workloadattestor/docker/docker_posix.go @@ -5,9 +5,7 @@ package docker import ( "errors" "fmt" - "io" "os" - "path/filepath" "regexp" "strconv" @@ -106,12 +104,6 @@ func (p *Plugin) createHelper(c *dockerPluginConfig, status *pluginconf.Status) } } -type dirFS string - -func (d dirFS) Open(p string) (io.ReadCloser, error) { - return os.Open(filepath.Join(string(d), p)) -} - type containerHelper struct { rootDir string containerIDFinder cgroup.ContainerIDFinder @@ -122,7 +114,7 @@ type containerHelper struct { func (h *containerHelper) getContainerIDAndSocket(pID int32, log hclog.Logger) (string, string, error) { if h.containerIDFinder != nil { - cgroupList, err := cgroups.GetCgroups(pID, dirFS(h.rootDir)) + cgroupList, err := cgroups.GetCgroups(pID, os.DirFS(h.rootDir)) if err != nil { return "", "", err } @@ -139,7 +131,7 @@ func (h *containerHelper) getContainerIDAndSocket(pID int32, log hclog.Logger) ( return "", "", err } - cgroupList, err := cgroups.GetCgroups(pID, dirFS(h.rootDir)) + cgroupList, err := cgroups.GetCgroups(pID, os.DirFS(h.rootDir)) if err != nil { log.Warn("Failed to read cgroups for Podman detection, falling back to Docker client", "pid", pID, "err", err) return containerID, "", nil diff --git a/pkg/agent/plugin/workloadattestor/docker/docker_posix_test.go b/pkg/agent/plugin/workloadattestor/docker/docker_posix_test.go index b7e411828d..3855374f37 100644 --- a/pkg/agent/plugin/workloadattestor/docker/docker_posix_test.go +++ b/pkg/agent/plugin/workloadattestor/docker/docker_posix_test.go @@ -8,7 +8,7 @@ import ( "path/filepath" "testing" - dockerclient "github.com/docker/docker/client" + dockerclient "github.com/moby/moby/client" "github.com/spiffe/spire/pkg/agent/plugin/workloadattestor/docker/cgroup" "github.com/spiffe/spire/test/spiretest" "github.com/stretchr/testify/require" diff --git a/pkg/agent/plugin/workloadattestor/docker/docker_test.go b/pkg/agent/plugin/workloadattestor/docker/docker_test.go index 2970f17855..8bdb3c7a56 100644 --- a/pkg/agent/plugin/workloadattestor/docker/docker_test.go +++ b/pkg/agent/plugin/workloadattestor/docker/docker_test.go @@ -8,10 +8,10 @@ import ( "testing" "time" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/image" - dockerclient "github.com/docker/docker/client" "github.com/hashicorp/go-hclog" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/image" + dockerclient "github.com/moby/moby/client" "github.com/spiffe/go-spiffe/v2/spiffeid" "github.com/spiffe/spire/pkg/agent/common/sigstore" "github.com/spiffe/spire/pkg/agent/plugin/workloadattestor" @@ -172,7 +172,7 @@ func TestDockerConfig(t *testing.T) { { name: "success configuration", trustDomain: "example.org", - config: `docker_version = "/123/"`, + config: `docker_version = "1.23"`, }, { name: "sigstore configuration", @@ -369,15 +369,17 @@ type dockerImageInspectError struct { cfg container.Config } -func (d dockerImageInspectError) ContainerInspect(_ context.Context, _ string) (container.InspectResponse, error) { - c := d.cfg - return container.InspectResponse{Config: &c}, nil +func (d dockerImageInspectError) ContainerInspect(_ context.Context, _ string, _ dockerclient.ContainerInspectOptions) (dockerclient.ContainerInspectResult, error) { + return dockerclient.ContainerInspectResult{ + Container: container.InspectResponse{ + Config: &d.cfg, + }, + }, nil } -func (dockerImageInspectError) ImageInspectWithRaw(context.Context, string) (image.InspectResponse, []byte, error) { - return image.InspectResponse{}, nil, errors.New("boom") +func (dockerImageInspectError) ImageInspect(_ context.Context, _ string, _ ...dockerclient.ImageInspectOption) (dockerclient.ImageInspectResult, error) { + return dockerclient.ImageInspectResult{}, errors.New("boom") } - func TestImageInspectError_NoSigstore_SkipsDigest(t *testing.T) { d := dockerImageInspectError{ cfg: container.Config{ @@ -420,17 +422,17 @@ type dockerNoRepoDigests struct { cfg container.Config } -func (d dockerNoRepoDigests) ContainerInspect(_ context.Context, _ string) (container.InspectResponse, error) { - c := d.cfg - return container.InspectResponse{Config: &c}, nil +func (d dockerNoRepoDigests) ContainerInspect(_ context.Context, _ string, _ dockerclient.ContainerInspectOptions) (dockerclient.ContainerInspectResult, error) { + return dockerclient.ContainerInspectResult{ + Container: container.InspectResponse{ + Config: &d.cfg, + }, + }, nil } -func (dockerNoRepoDigests) ImageInspectWithRaw(_ context.Context, _ string) (image.InspectResponse, []byte, error) { - // ID is present so image_config_digest would be emitted if we reached that point; - // empty RepoDigests should cause sigstore path to fail. - return image.InspectResponse{ID: "sha256:abc"}, nil, nil +func (dockerNoRepoDigests) ImageInspect(_ context.Context, _ string, _ ...dockerclient.ImageInspectOption) (dockerclient.ImageInspectResult, error) { + return dockerclient.ImageInspectResult{InspectResponse: image.InspectResponse{ID: "sha256:abc"}}, nil } - func TestSigstore_NoRepoDigests_Fatal(t *testing.T) { d := dockerNoRepoDigests{ cfg: container.Config{Image: testImageID}, @@ -518,28 +520,30 @@ func newTestPlugin(t *testing.T, opts ...testPluginOpt) *Plugin { type dockerError struct{} -func (dockerError) ContainerInspect(context.Context, string) (container.InspectResponse, error) { - return container.InspectResponse{}, errors.New("docker error") +func (dockerError) ContainerInspect(_ context.Context, _ string, _ dockerclient.ContainerInspectOptions) (dockerclient.ContainerInspectResult, error) { + return dockerclient.ContainerInspectResult{}, errors.New("docker error") } -func (dockerError) ImageInspectWithRaw(context.Context, string) (image.InspectResponse, []byte, error) { - return image.InspectResponse{}, nil, errors.New("docker error") +func (dockerError) ImageInspect(_ context.Context, _ string, _ ...dockerclient.ImageInspectOption) (dockerclient.ImageInspectResult, error) { + return dockerclient.ImageInspectResult{}, errors.New("docker error") } type fakeContainer container.Config -func (f fakeContainer) ContainerInspect(_ context.Context, containerID string) (container.InspectResponse, error) { +func (f fakeContainer) ContainerInspect(_ context.Context, containerID string, _ dockerclient.ContainerInspectOptions) (dockerclient.ContainerInspectResult, error) { if containerID != testContainerID { - return container.InspectResponse{}, errors.New("expected test container ID") + return dockerclient.ContainerInspectResult{}, errors.New("expected test container ID") } - config := container.Config(f) - return container.InspectResponse{ - Config: &config, + return dockerclient.ContainerInspectResult{ + Container: container.InspectResponse{ + Config: new(container.Config(f)), + }, }, nil } -func (f fakeContainer) ImageInspectWithRaw(_ context.Context, imageName string) (image.InspectResponse, []byte, error) { - return image.InspectResponse{ID: imageName, RepoDigests: []string{testImageID}}, nil, nil +func (f fakeContainer) ImageInspect(_ context.Context, imageID string, _ ...dockerclient.ImageInspectOption) (dockerclient.ImageInspectResult, error) { + return dockerclient.ImageInspectResult{InspectResponse: image.InspectResponse{ + ID: imageID, RepoDigests: []string{testImageID}}}, nil } type fakeSigstoreVerifier struct { diff --git a/pkg/agent/plugin/workloadattestor/k8s/k8s.go b/pkg/agent/plugin/workloadattestor/k8s/k8s.go index c7b2ac653b..995ea25bc8 100644 --- a/pkg/agent/plugin/workloadattestor/k8s/k8s.go +++ b/pkg/agent/plugin/workloadattestor/k8s/k8s.go @@ -21,8 +21,11 @@ import ( "github.com/hashicorp/go-hclog" "github.com/hashicorp/hcl" hcltoken "github.com/hashicorp/hcl/hcl/token" + "github.com/spiffe/go-spiffe/v2/exp/proto/spiffe/broker" + "github.com/spiffe/go-spiffe/v2/spiffeid" workloadattestorv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/agent/workloadattestor/v1" configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" + "github.com/spiffe/spire/pkg/agent/broker/brokercontext" "github.com/spiffe/spire/pkg/agent/common/sigstore" "github.com/spiffe/spire/pkg/common/catalog" "github.com/spiffe/spire/pkg/common/pemutil" @@ -32,19 +35,60 @@ import ( "golang.org/x/sync/singleflight" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/anypb" + authv1 "k8s.io/api/authorization/v1" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + crcache "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" ) +var k8sScheme = runtime.NewScheme() + +func init() { + if err := metav1.AddMetaToScheme(k8sScheme); err != nil { + panic(fmt.Sprintf("failed to register metav1 scheme: %v", err)) + } + if err := corev1.AddToScheme(k8sScheme); err != nil { + panic(fmt.Sprintf("failed to register corev1 scheme: %v", err)) + } + if err := authv1.AddToScheme(k8sScheme); err != nil { + panic(fmt.Sprintf("failed to register authv1 scheme: %v", err)) + } +} + const ( - pluginName = "k8s" - defaultMaxPollAttempts = 60 - defaultPollRetryInterval = time.Millisecond * 500 - defaultSecureKubeletPort = 10250 - defaultKubeletCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" - defaultTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint: gosec // false positive - defaultNodeNameEnv = "MY_NODE_NAME" - defaultReloadInterval = time.Minute + pluginName = "k8s" + brokerImpersonationReviewVerb = "impersonate-via-spire" + defaultMaxPollAttempts = 60 + defaultPollRetryInterval = time.Millisecond * 500 + defaultSecureKubeletPort = 10250 + defaultKubeletCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + defaultTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint: gosec // false positive + defaultNodeNameEnv = "MY_NODE_NAME" + defaultReloadInterval = time.Minute + + workloadPIDReferenceTypeURL = "type.googleapis.com/spiffe.broker.WorkloadPIDReference" + kubernetesObjectReferenceTypeURL = "type.googleapis.com/spiffe.broker.KubernetesObjectReference" +) + +type podReferenceScope string + +const ( + podReferenceScopeAgentNode podReferenceScope = "agent_node" + podReferenceScopeCluster podReferenceScope = "cluster" +) + +var ( + ErrNamespaceRequired = status.Error(codes.InvalidArgument, "namespace is required when name is set for a namespaced resource") ) func BuiltIn() catalog.BuiltIn { @@ -137,9 +181,55 @@ type HCLConfig struct { // Sigstore contains sigstore specific configs. Sigstore *sigstore.HCLConfig `hcl:"sigstore,omitempty"` + // Experimental contains experimental configs. + Experimental *k8sExperimentalHCLConfig `hcl:"experimental"` + + UnusedKeyPositions map[string][]hcltoken.Pos `hcl:",unusedKeyPositions"` +} + +type k8sExperimentalHCLConfig struct { + // APIServer contains Kubernetes API server-specific configs. + APIServer *k8sAPIServerHCLConfig `hcl:"api_server"` + + // Broker contains SPIFFE Broker API-specific configuration. + Broker *k8sBrokerHCLConfig `hcl:"broker"` + UnusedKeyPositions map[string][]hcltoken.Pos `hcl:",unusedKeyPositions"` } +type k8sBrokerHCLConfig struct { + // AccessPolicy controls whether the plugin creates SubjectAccessReview + // requests to authorize broker access to resolved Kubernetes objects. + AccessPolicy string `hcl:"access_policy"` + + Brokers []k8sBrokerHCLEntry `hcl:"brokers"` + + UnusedKeyPositions map[string][]hcltoken.Pos `hcl:",unusedKeyPositions"` +} + +type k8sBrokerHCLEntry struct { + ID string `hcl:"id"` + PodReferenceScope string `hcl:"pod_reference_scope"` + + UnusedKeyPositions map[string][]hcltoken.Pos `hcl:",unusedKeyPositions"` +} + +type k8sAPIServerHCLConfig struct { + Cache *k8sAPIServerCacheHCLConfig `hcl:"cache"` + + UnusedKeyPositions map[string][]hcltoken.Pos `hcl:",unusedKeyPositions"` +} + +type k8sAPIServerCacheHCLConfig struct { + Enabled bool `hcl:"enabled"` + + UnusedKeyPositions map[string][]hcltoken.Pos `hcl:",unusedKeyPositions"` +} + +type k8sAPIServerCacheConfig struct { + Enabled bool +} + // k8sConfig holds the configuration distilled from HCL type k8sConfig struct { Secure bool @@ -157,6 +247,8 @@ type k8sConfig struct { DisableContainerSelectors bool ContainerHelper ContainerHelper sigstoreConfig *sigstore.Config + APIServerCache k8sAPIServerCacheConfig + Broker *k8sBrokerConfig Client *kubeletClient LastReload time.Time @@ -171,6 +263,7 @@ func (p *Plugin) buildConfig(coreConfig catalog.CoreConfig, hclText string, stat } pluginconf.ReportUnusedKeys(status, newConfig.UnusedKeyPositions) + apiServerCacheConfig, brokerConfig := buildExperimentalConfig(newConfig.Experimental, status) // Determine max poll attempts with default maxPollAttempts := newConfig.MaxPollAttempts @@ -252,6 +345,125 @@ func (p *Plugin) buildConfig(coreConfig catalog.CoreConfig, hclText string, stat DisableContainerSelectors: newConfig.DisableContainerSelectors, ContainerHelper: containerHelper, sigstoreConfig: sigstoreConfig, + APIServerCache: apiServerCacheConfig, + Broker: brokerConfig, + } +} + +func buildAPIServerCacheConfig(hclConfig *k8sAPIServerHCLConfig, status *pluginconf.Status) k8sAPIServerCacheConfig { + if hclConfig == nil { + return k8sAPIServerCacheConfig{} + } + pluginconf.ReportUnusedKeys(status, hclConfig.UnusedKeyPositions) + if hclConfig.Cache == nil { + return k8sAPIServerCacheConfig{} + } + pluginconf.ReportUnusedKeys(status, hclConfig.Cache.UnusedKeyPositions) + return k8sAPIServerCacheConfig{ + Enabled: hclConfig.Cache.Enabled, + } +} + +func buildExperimentalConfig(hclConfig *k8sExperimentalHCLConfig, status *pluginconf.Status) (k8sAPIServerCacheConfig, *k8sBrokerConfig) { + if hclConfig == nil { + return k8sAPIServerCacheConfig{}, nil + } + pluginconf.ReportUnusedKeys(status, hclConfig.UnusedKeyPositions) + return buildAPIServerCacheConfig(hclConfig.APIServer, status), buildBrokerConfig("experimental.broker", hclConfig.Broker, status) +} + +type brokerAccessPolicy string + +const ( + brokerAccessPolicyPermissive brokerAccessPolicy = "permissive" + brokerAccessPolicyEnforced brokerAccessPolicy = "enforced" +) + +type k8sBrokerConfig struct { + AccessPolicy brokerAccessPolicy + Brokers map[string]k8sBrokerEntry +} + +type k8sBrokerEntry struct { + ID spiffeid.ID + PodReferenceScope podReferenceScope +} + +func buildBrokerConfig(path string, brokerConfig *k8sBrokerHCLConfig, status *pluginconf.Status) *k8sBrokerConfig { + if brokerConfig == nil { + return nil + } + + pluginconf.ReportUnusedKeys(status, brokerConfig.UnusedKeyPositions) + accessPolicy, _ := buildBrokerAccessPolicy(path, brokerConfig.AccessPolicy, status) + if len(brokerConfig.Brokers) == 0 { + status.ReportErrorf("%s.brokers: at least one broker is required", path) + return &k8sBrokerConfig{ + AccessPolicy: accessPolicy, + Brokers: map[string]k8sBrokerEntry{}, + } + } + + brokers := make(map[string]k8sBrokerEntry, len(brokerConfig.Brokers)) + seen := make(map[string]struct{}, len(brokerConfig.Brokers)) + for i, b := range brokerConfig.Brokers { + pluginconf.ReportUnusedKeys(status, b.UnusedKeyPositions) + if b.ID == "" { + status.ReportErrorf("%s.brokers[%d].id: must be specified", path, i) + continue + } + if _, dup := seen[b.ID]; dup { + status.ReportErrorf("%s.brokers[%s].id: duplicate broker id", path, b.ID) + continue + } + seen[b.ID] = struct{}{} + + id, err := spiffeid.FromString(b.ID) + if err != nil { + status.ReportErrorf("%s.brokers[%s].id: %v", path, b.ID, err) + continue + } + + podRefScope, ok := buildPodReferenceScope(path, b.ID, b.PodReferenceScope, status) + if !ok { + continue + } + + brokers[b.ID] = k8sBrokerEntry{ + ID: id, + PodReferenceScope: podRefScope, + } + } + return &k8sBrokerConfig{ + AccessPolicy: accessPolicy, + Brokers: brokers, + } +} + +func buildBrokerAccessPolicy(path string, hclValue string, status *pluginconf.Status) (brokerAccessPolicy, bool) { + switch hclValue { + case string(brokerAccessPolicyPermissive): + return brokerAccessPolicyPermissive, true + case string(brokerAccessPolicyEnforced): + return brokerAccessPolicyEnforced, true + case "": + status.ReportErrorf("%s.access_policy: must be specified as one of [permissive, enforced]", path) + return "", false + default: + status.ReportErrorf("%s.access_policy: unsupported value %q; must be one of [permissive, enforced]", path, hclValue) + return "", false + } +} + +func buildPodReferenceScope(path string, brokerID string, hclValue string, status *pluginconf.Status) (podReferenceScope, bool) { + switch hclValue { + case string(podReferenceScopeAgentNode), "": + return podReferenceScopeAgentNode, true + case string(podReferenceScopeCluster): + return podReferenceScopeCluster, true + default: + status.ReportErrorf("%s.brokers[%s].pod_reference_scope: unsupported value %q; must be one of [agent_node, cluster]", path, brokerID, hclValue) + return "", false } } @@ -274,6 +486,21 @@ type Plugin struct { containerHelper ContainerHelper sigstoreVerifier sigstore.Verifier + // kubeClient is a live, uncached Kubernetes API server client for full + // objects and writes, like Pod reads and SubjectAccessReview creation. + // kubeMetadataClient is a Kubernetes API server client used only for + // PartialObjectMetadata lookups; when + // experimental.api_server.cache.enabled is true, this client is + // cache-backed. Both carry their own RESTMapper internally (accessible via + // client.RESTMapper()), so we don't keep a separate mapper + // field. Guarded by a dedicated mutex so apiserver discovery handshakes do + // not block readers of unrelated plugin state. + kubeMu sync.RWMutex + kubeClient client.Client + kubeMetadataClient client.Client + kubeCacheCancel context.CancelFunc + kubeCacheDone chan struct{} + cachedPodList map[string]*fastjson.Value cachedPodListValidUntil time.Time singleflight singleflight.Group @@ -290,13 +517,109 @@ func (p *Plugin) SetLogger(log hclog.Logger) { p.log = log } +// Attest implements the PID-based workload attestor RPC. PID handling is +// delegated through AttestReference so both RPCs share behavior, including +// broker checks when broker metadata is present on the context. func (p *Plugin) Attest(ctx context.Context, req *workloadattestorv1.AttestRequest) (*workloadattestorv1.AttestResponse, error) { + ref, err := anypb.New(&broker.WorkloadPIDReference{Pid: req.Pid}) + if err != nil { + return nil, err + } + resp, err := p.AttestReference(ctx, &workloadattestorv1.AttestReferenceRequest{Reference: ref}) + if err != nil { + return nil, err + } + return &workloadattestorv1.AttestResponse{SelectorValues: resp.GetSelectorValues()}, nil +} + +func (p *Plugin) AttestReference(ctx context.Context, req *workloadattestorv1.AttestReferenceRequest) (*workloadattestorv1.AttestReferenceResponse, error) { + config, _, _, err := p.getConfig() + if err != nil { + return nil, err + } + + brokerEntry, result, err := p.attestReference(ctx, config, req) + if err != nil { + return nil, err + } + if err := p.checkBrokerImpersonationForReference(ctx, config, brokerEntry, result); err != nil { + return nil, err + } + return result.Response, nil +} + +func (p *Plugin) attestReference(ctx context.Context, config *k8sConfig, req *workloadattestorv1.AttestReferenceRequest) (*k8sBrokerEntry, *attestReferenceResult, error) { + reference := req.GetReference() + if reference == nil { + return nil, nil, status.Error(codes.InvalidArgument, "workload reference must be provided") + } + + brokerEntry, err := p.getBrokerEntryIfPresent(ctx, config) + if err != nil { + return nil, nil, err + } + + switch reference.TypeUrl { + case workloadPIDReferenceTypeURL: + var pidRef broker.WorkloadPIDReference + if err := reference.UnmarshalTo(&pidRef); err != nil { + return nil, nil, status.Errorf(codes.InvalidArgument, "unable to unmarshal PID reference: %v", err) + } + result, err := p.attestByPIDReference(ctx, pidRef.Pid) + return brokerEntry, result, err + case kubernetesObjectReferenceTypeURL: + var objRef broker.KubernetesObjectReference + if err := reference.UnmarshalTo(&objRef); err != nil { + return nil, nil, status.Errorf(codes.InvalidArgument, "unable to unmarshal object reference: %v", err) + } + if err := validateKubernetesObjectReference(&objRef); err != nil { + return nil, nil, err + } + result, err := p.attestByKubernetesObjectReference(ctx, brokerEntry, &objRef) + return brokerEntry, result, err + default: + return nil, nil, status.Errorf(codes.InvalidArgument, "unsupported reference type: %s", reference.TypeUrl) + } +} + +func validateKubernetesObjectReference(objRef *broker.KubernetesObjectReference) error { + objType := objRef.GetType() + if objType == nil { + return status.Error(codes.InvalidArgument, "object reference is missing type") + } + if objType.Plural == "" { + return status.Error(codes.InvalidArgument, "object reference type is missing plural") + } + if objType.Group == "" { + return status.Error(codes.InvalidArgument, "object reference type is missing group") + } + objKey := objRef.GetKey() + if objKey == nil && objRef.GetUid() == "" { + return status.Error(codes.InvalidArgument, "object reference is missing key and UID") + } + if objKey != nil { + name := objKey.GetName() + if name == "" { + return status.Error(codes.InvalidArgument, "object reference key is missing name") + } + } + return nil +} + +type attestReferenceResult struct { + Response *workloadattestorv1.AttestReferenceResponse + ObjectReference *broker.KubernetesObjectReference + Namespace string + Name string +} + +func (p *Plugin) attestByPIDReference(ctx context.Context, pid int32) (*attestReferenceResult, error) { config, containerHelper, sigstoreVerifier, err := p.getConfig() if err != nil { return nil, err } - podUID, containerID, err := containerHelper.GetPodUIDAndContainerID(req.Pid, p.log) + podUID, containerID, err := containerHelper.GetPodUIDAndContainerID(pid, p.log) if err != nil { return nil, err } @@ -304,7 +627,7 @@ func (p *Plugin) Attest(ctx context.Context, req *workloadattestorv1.AttestReque // Not a Kubernetes pod if containerID == "" { - return &workloadattestorv1.AttestResponse{}, nil + return &attestReferenceResult{Response: &workloadattestorv1.AttestReferenceResponse{}}, nil } log := p.log.With( @@ -323,7 +646,7 @@ func (p *Plugin) Attest(ctx context.Context, req *workloadattestorv1.AttestReque return nil, err } - var attestResponse *workloadattestorv1.AttestResponse + var result *attestReferenceResult for podKey, podValue := range podList { if podKnown { if podKey != string(podUID) { @@ -371,16 +694,23 @@ func (p *Plugin) Attest(ctx context.Context, req *workloadattestorv1.AttestReque } if len(selectorValues) > 0 { - if attestResponse != nil { + if result != nil { log.Warn("Two pods found with same container Id") return nil, status.Error(codes.Internal, "two pods found with same container Id") } - attestResponse = &workloadattestorv1.AttestResponse{SelectorValues: selectorValues} + result = &attestReferenceResult{ + Response: &workloadattestorv1.AttestReferenceResponse{SelectorValues: selectorValues}, + ObjectReference: &broker.KubernetesObjectReference{ + Type: &broker.KubernetesObjectType{Plural: "pods", Group: "core"}, + }, + Namespace: pod.Namespace, + Name: pod.Name, + } } } - if attestResponse != nil { - return attestResponse, nil + if result != nil { + return result, nil } // if the container was not located after the maximum number of attempts then the search is over. @@ -400,6 +730,583 @@ func (p *Plugin) Attest(ctx context.Context, req *workloadattestorv1.AttestReque } } +func (p *Plugin) attestByKubernetesObjectReference(ctx context.Context, brokerEntry *k8sBrokerEntry, objRef *broker.KubernetesObjectReference) (*attestReferenceResult, error) { + objType := objRef.GetType() + switch { + case objType.Plural == "pods" && objType.Group == "core": + return p.attestByPodReference(ctx, brokerEntry, objRef) + default: + return p.attestByObjectReference(ctx, objRef) + } +} + +func (p *Plugin) checkBrokerImpersonationForReference(ctx context.Context, config *k8sConfig, brokerEntry *k8sBrokerEntry, result *attestReferenceResult) error { + if brokerEntry == nil { + return nil + } + if config.Broker == nil || config.Broker.AccessPolicy != brokerAccessPolicyEnforced { + return nil + } + objRef := result.ObjectReference + if objRef == nil { + return nil + } + kubeClient, err := p.getOrCreateKubeClient() + if err != nil { + return status.Errorf(codes.Internal, "unable to set up Kubernetes client: %v", err) + } + review := &authv1.SubjectAccessReview{ + Spec: authv1.SubjectAccessReviewSpec{ + User: brokerEntry.ID.String(), + ResourceAttributes: &authv1.ResourceAttributes{ + Group: kubernetesAPIGroup(objRef.GetType().GetGroup()), + Resource: objRef.GetType().GetPlural(), + Namespace: result.Namespace, + Name: result.Name, + Verb: brokerImpersonationReviewVerb, + }, + }, + } + if err := kubeClient.Create(ctx, review); err != nil { + return status.Errorf(codes.Internal, "unable to check Kubernetes authorization for broker: %v", err) + } + if !review.Status.Allowed { + return status.Error(codes.PermissionDenied, "Kubernetes authorizer does not allow the broker to use impersonate-via-spire for the referenced object") + } + return nil +} + +func (p *Plugin) getBrokerEntryIfPresent(ctx context.Context, config *k8sConfig) (*k8sBrokerEntry, error) { + callerID, ok, err := brokercontext.CallerIDFromContext(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "unable to determine broker caller identity: %v", err) + } + if !ok { + return nil, nil + } + if config.Broker == nil { + return nil, status.Error(codes.Internal, "broker configuration missing") + } + brokerEntry, ok := config.Broker.Brokers[callerID.String()] + if !ok { + return nil, status.Errorf(codes.PermissionDenied, "broker %q is not configured", callerID.String()) + } + return &brokerEntry, nil +} + +// attestByPodReference handles the `pods/core` path: a Kubernetes object +// reference whose resource is the core Pod type. Reference shape is already +// validated by the AttestReference dispatcher (at least one of key/uid; name +// required when key is set); this function adds the pod-specific namespace +// requirement (pods are always namespaced) and enforces the spec cross-check +// ("if both key and uid are supplied, the resolved pod's UID MUST match the +// supplied uid"). Resolution tries the kubelet pod list first (cheap, +// node-local, indexed by UID — same path the PID-based flow uses). With +// agent_node scope, resolution is limited to that kubelet pod list and does +// not fall back to the API server. Selector emission uses pod-shaped selectors +// (sa, ns, pod-uid, pod-name, pod-image, pod-label, pod-owner, ...), distinct +// from the generic-object vocabulary so registration entries +// can match pod-specific fields like container images and service accounts +// that aren't present on a PartialObjectMetadata. +func (p *Plugin) attestByPodReference(ctx context.Context, brokerEntry *k8sBrokerEntry, objRef *broker.KubernetesObjectReference) (*attestReferenceResult, error) { + key := objRef.GetKey() + namespace := key.GetNamespace() + name := key.GetName() + uid := types.UID(objRef.GetUid()) + + config, _, _, err := p.getConfig() + if err != nil { + return nil, err + } + + var pod *corev1.Pod + switch { + case name != "": + if namespace == "" { + return nil, ErrNamespaceRequired + } + pod, err = p.findPodByName(ctx, config, namespace, name, brokerPodReferenceScope(brokerEntry)) + default: + pod, err = p.findPodByUID(ctx, config, uid, brokerPodReferenceScope(brokerEntry)) + } + if err != nil { + return nil, err + } + + // Per spec: when name (and namespace) and uid are both supplied, the + // resolved object's UID MUST match the supplied uid. + if uid != "" && pod.UID != uid { + return nil, status.Errorf(codes.NotFound, "pod %s/%s has UID %s, expected %s", pod.Namespace, pod.Name, pod.UID, uid) + } + + return &attestReferenceResult{ + Response: &workloadattestorv1.AttestReferenceResponse{SelectorValues: getSelectorValuesFromPodInfo(pod)}, + ObjectReference: objRef, + Namespace: pod.Namespace, + Name: pod.Name, + }, nil +} + +func brokerPodReferenceScope(brokerEntry *k8sBrokerEntry) podReferenceScope { + if brokerEntry == nil { + return podReferenceScopeAgentNode + } + return brokerEntry.PodReferenceScope +} + +// findPodByName resolves a single pod by its namespaced name. The kubelet +// pod list is iterated first; this is O(n) over the node's pods (the list +// is indexed by UID, not name) but n is small in practice and saves an API +// server round-trip when the pod is local. Under agent_node scope, resolution +// stops at the kubelet pod list. Under cluster scope, if the pod is not in the +// kubelet list, the apiserver answers a precise Get directly — no list, no +// client-side filter. +func (p *Plugin) findPodByName(ctx context.Context, config *k8sConfig, namespace, name string, scope podReferenceScope) (*corev1.Pod, error) { + // Try kubelet pod list first; iterate to find a match by namespace+name. + podList, err := p.getPodList(ctx, config.Client, config.PollRetryInterval/2) + if err != nil { + return nil, err + } + for _, podValue := range podList { + if string(podValue.GetStringBytes("metadata", "namespace")) != namespace { + continue + } + if string(podValue.GetStringBytes("metadata", "name")) != name { + continue + } + return decodePodFromKubelet(podValue) + } + + if scope == podReferenceScopeAgentNode { + return nil, status.Errorf(codes.NotFound, "pod %s/%s not found on agent node", namespace, name) + } + + // Fallback: direct Get from API server. + kubeClient, err := p.getOrCreateKubeClient() + if err != nil { + return nil, status.Errorf(codes.Internal, "unable to create Kubernetes client: %v", err) + } + pod := &corev1.Pod{} + if err := kubeClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, pod); err != nil { + if apierrors.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "pod %s/%s not found", namespace, name) + } + return nil, status.Errorf(codes.Internal, "unable to get pod from Kubernetes API: %v", err) + } + return pod, nil +} + +// findPodByUID resolves a single pod by its Kubernetes UID. The kubelet pod +// list is checked first because it's already keyed by UID and only contains +// pods scheduled to this node — both common-case wins. Under agent_node scope, +// resolution stops at the kubelet pod list. Under cluster scope, if the pod is +// not in the kubelet list, it falls back to a cluster-wide +// PartialObjectMetadata List from the API server cache to resolve the pod +// name+namespace, then fetches the full pod with a single live Get. Kubernetes +// does not support `metadata.uid` as a field selector, so we list and filter +// client-side regardless. +func (p *Plugin) findPodByUID(ctx context.Context, config *k8sConfig, uid types.UID, scope podReferenceScope) (*corev1.Pod, error) { + // Try kubelet pod list first (already indexed by UID). + podList, err := p.getPodList(ctx, config.Client, config.PollRetryInterval/2) + if err != nil { + return nil, err + } + if podValue, ok := podList[string(uid)]; ok { + return decodePodFromKubelet(podValue) + } + + if scope == podReferenceScopeAgentNode { + return nil, status.Errorf(codes.NotFound, "pod with UID %s not found on agent node", uid) + } + + // Fallback: list pod metadata to resolve UID -> namespace/name, then do a + // single live Get for the full pod object. + kubeMetadataClient, err := p.getOrCreateKubeMetadataClient(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "unable to create Kubernetes metadata client: %v", err) + } + podMeta, err := p.findObject(ctx, kubeMetadataClient, schema.GroupVersionKind{Version: "v1", Kind: "Pod"}, "", "", uid) + if err != nil { + return nil, err + } + + kubeClient, err := p.getOrCreateKubeClient() + if err != nil { + return nil, status.Errorf(codes.Internal, "unable to create Kubernetes client: %v", err) + } + pod := &corev1.Pod{} + if err := kubeClient.Get(ctx, client.ObjectKey{Namespace: podMeta.Namespace, Name: podMeta.Name}, pod); err != nil { + if apierrors.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "pod %s/%s not found", podMeta.Namespace, podMeta.Name) + } + return nil, status.Errorf(codes.Internal, "unable to get pod from Kubernetes API: %v", err) + } + if pod.UID != uid { + return nil, status.Errorf(codes.NotFound, "pod %s/%s has UID %s, expected %s", pod.Namespace, pod.Name, pod.UID, uid) + } + return pod, nil +} + +// decodePodFromKubelet rehydrates a `corev1.Pod` from the partially-parsed +// JSON the kubelet pod-list path keeps in fastjson form (the cache stores +// pods as `*fastjson.Value` to avoid per-request unmarshalling when no pod +// is needed). The two-stage marshal-then-unmarshal is unavoidable here: +// fastjson is read-only, so the only way to project into a typed struct is +// to serialise back to bytes and let `encoding/json` parse it. +func decodePodFromKubelet(podValue *fastjson.Value) (*corev1.Pod, error) { + var scratch []byte + scratch = podValue.MarshalTo(scratch) + pod := new(corev1.Pod) + if err := json.Unmarshal(scratch, pod); err != nil { + return nil, status.Errorf(codes.Internal, "unable to decode pod info from kubelet response: %v", err) + } + return pod, nil +} + +// attestByObjectReference handles the generic-Kubernetes-object path: any +// resource other than `pods/core`. It resolves the resource's GVK and scope +// via the discovery-backed REST mapper, fetches the object's metadata via +// PartialObjectMetadata, and emits a uniform set of selectors derived from +// `ObjectMeta` (resource, namespace, name, uid, labels, owner references). +func (p *Plugin) attestByObjectReference(ctx context.Context, objRef *broker.KubernetesObjectReference) (*attestReferenceResult, error) { + r := objRef.GetType() + key := objRef.GetKey() + namespace := key.GetNamespace() + name := key.GetName() + uid := types.UID(objRef.GetUid()) + + kubeClient, err := p.getOrCreateKubeMetadataClient(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "unable to set up Kubernetes client: %v", err) + } + mapper := kubeClient.RESTMapper() + + // Per the SPIFFE Broker API spec, `core` is the canonical group string + // for the Kubernetes core API group, but Kubernetes itself uses the + // empty string on the wire — translate before mapping. + group := kubernetesAPIGroup(r.GetGroup()) + gvr := schema.GroupVersionResource{Group: group, Resource: r.GetPlural()} + gvk, err := mapper.KindFor(gvr) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "unknown Kubernetes resource %s.%s: %v", r.GetPlural(), r.GetGroup(), err) + } + mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return nil, status.Errorf(codes.Internal, "no REST mapping for %s.%s: %v", r.GetPlural(), r.GetGroup(), err) + } + + namespaced := mapping.Scope.Name() == meta.RESTScopeNameNamespace + switch { + case namespaced && name != "" && namespace == "": + return nil, ErrNamespaceRequired + case !namespaced && namespace != "": + return nil, status.Error(codes.InvalidArgument, "namespace must be empty for cluster-scoped resource") + } + + obj, err := p.findObject(ctx, kubeClient, gvk, namespace, name, uid) + if err != nil { + return nil, err + } + + if uid != "" && obj.UID != uid { + return nil, status.Errorf(codes.NotFound, "%s.%s %s/%s has UID %s, expected %s", + r.GetPlural(), r.GetGroup(), obj.Namespace, obj.Name, obj.UID, uid) + } + return &attestReferenceResult{ + Response: &workloadattestorv1.AttestReferenceResponse{ + SelectorValues: getSelectorValuesFromObjectMeta(r, gvk, obj), + }, + ObjectReference: objRef, + Namespace: obj.Namespace, + Name: obj.Name, + }, nil +} + +func kubernetesAPIGroup(group string) string { + if group == "core" { + return "" + } + return group +} + +// findObject resolves a single Kubernetes object's metadata. When `name` is +// supplied, a direct Get is used (precise; returns NotFound cleanly). When +// only `uid` is supplied, the API server is listed and filtered client-side +// (the apiserver does not support metadata.uid as a field selector). +func (p *Plugin) findObject(ctx context.Context, kubeClient client.Client, gvk schema.GroupVersionKind, namespace, name string, uid types.UID) (*metav1.PartialObjectMetadata, error) { + if name != "" { + obj := &metav1.PartialObjectMetadata{} + obj.SetGroupVersionKind(gvk) + if err := kubeClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, obj); err != nil { + if apierrors.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "%s %s/%s not found", gvk.Kind, namespace, name) + } + return nil, status.Errorf(codes.Internal, "unable to get %s: %v", gvk.Kind, err) + } + return obj, nil + } + + list := &metav1.PartialObjectMetadataList{} + list.SetGroupVersionKind(schema.GroupVersionKind{Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind + "List"}) + // UnsafeDisableDeepCopy lets the cache return references into its store. + // This path only reads metadata and never mutates returned objects. + if err := kubeClient.List(ctx, list, client.UnsafeDisableDeepCopy); err != nil { + return nil, status.Errorf(codes.Internal, "unable to list %s: %v", gvk.Kind, err) + } + for i := range list.Items { + if list.Items[i].UID == uid { + return &list.Items[i], nil + } + } + return nil, status.Errorf(codes.NotFound, "%s with UID %s not found", gvk.Kind, uid) +} + +// getSelectorValuesFromObjectMeta produces the broker-object selector set +// from a resolved Kubernetes object's metadata. The vocabulary is uniform +// across resource types so registration entries can be authored without +// caring whether the workload is a Pod, a Deployment, or a CRD instance. +// +// Selector values (the leading `k8s:` selector type is added by the V1 +// wrapper, not here): +// +// uid: always +// resource:. always +// plural: always +// group: always; "core" for core resources +// version: always; e.g. "v1", "v1beta1" +// apiVersion: Kubernetes wire form: "v1" (core) or "/" +// kind: always; e.g. "Pod", "Deployment" +// name: always +// namespace: omitted for cluster-scoped objects +// key:/ `/` for namespaced objects; `` for cluster-scoped +// label:: one per .metadata.labels entry +// owner-key:// atomic identity of every owner reference by key +// owner-uid:// atomic identity of every owner reference by UID +// controller-key:// atomic identity of every controller owner reference by key +// controller-uid:// atomic identity of every controller owner reference by UID +func getSelectorValuesFromObjectMeta(r *broker.KubernetesObjectType, gvk schema.GroupVersionKind, obj *metav1.PartialObjectMetadata) []string { + objType := r.GetPlural() + "." + r.GetGroup() + values := []string{ + "uid:" + string(obj.UID), + "resource:" + objType, + "plural:" + r.GetPlural(), + "group:" + r.GetGroup(), + "version:" + gvk.Version, + "apiVersion:" + gvk.GroupVersion().String(), + "kind:" + gvk.Kind, + "name:" + obj.Name, + } + + // Namespace and key. + objKey := obj.Name + if obj.Namespace != "" { + objKey = obj.Namespace + "/" + obj.Name + values = append(values, "namespace:"+obj.Namespace) + } + values = append(values, "key:"+objKey) + + // Labels. + for k, v := range obj.Labels { + values = append(values, "label:"+k+":"+v) + } + + // Owners and controllers. + for _, owner := range obj.OwnerReferences { + ownerGV, _ := schema.ParseGroupVersion(owner.APIVersion) + if ownerGV.Group == "" { + ownerGV.Group = "core" + } + + // We omit ownerGV.Version here on purpose, the version is + // not part of the object identity. + ownerGK := ownerGV.Group + "/" + owner.Kind + ownerKey := ownerGK + "/" + owner.Name + ownerUID := ownerGK + "/" + string(owner.UID) + values = append(values, + "owner-key:"+ownerKey, + "owner-uid:"+ownerUID, + ) + + // Also a controller? + if owner.Controller != nil && *owner.Controller { + values = append(values, + "controller-key:"+ownerKey, + "controller-uid:"+ownerUID, + ) + } + } + return values +} + +// getOrCreateKubeClient lazily builds a live, uncached controller-runtime +// client for full object reads and writes. +func (p *Plugin) getOrCreateKubeClient() (client.Client, error) { + p.kubeMu.RLock() + if p.kubeClient != nil { + c := p.kubeClient + p.kubeMu.RUnlock() + return c, nil + } + p.kubeMu.RUnlock() + + p.kubeMu.Lock() + defer p.kubeMu.Unlock() + if p.kubeClient != nil { + return p.kubeClient, nil + } + + restConfig, clientOptions, err := buildKubeClientOptions() + if err != nil { + return nil, err + } + c, err := client.New(restConfig, clientOptions) + if err != nil { + return nil, fmt.Errorf("unable to create Kubernetes client: %w", err) + } + p.kubeClient = c + return c, nil +} + +// getOrCreateKubeMetadataClient lazily builds the controller-runtime client +// used for PartialObjectMetadata. When the API server cache is enabled, only +// this client is cache-backed. +func (p *Plugin) getOrCreateKubeMetadataClient(ctx context.Context) (client.Client, error) { + p.kubeMu.RLock() + if p.kubeMetadataClient != nil { + c := p.kubeMetadataClient + p.kubeMu.RUnlock() + return c, nil + } + p.kubeMu.RUnlock() + + p.kubeMu.Lock() + defer p.kubeMu.Unlock() + if p.kubeMetadataClient != nil { + return p.kubeMetadataClient, nil + } + + config, _, _, err := p.getConfig() + if err != nil { + return nil, err + } + if !config.APIServerCache.Enabled { + if p.kubeClient != nil { + p.kubeMetadataClient = p.kubeClient + return p.kubeMetadataClient, nil + } + restConfig, clientOptions, err := buildKubeClientOptions() + if err != nil { + return nil, err + } + c, err := client.New(restConfig, clientOptions) + if err != nil { + return nil, fmt.Errorf("unable to create Kubernetes client: %w", err) + } + p.kubeClient = c + p.kubeMetadataClient = c + return c, nil + } + + restConfig, clientOptions, err := buildKubeClientOptions() + if err != nil { + return nil, err + } + var liveClient client.Client + if p.kubeClient == nil { + liveClient, err = client.New(restConfig, clientOptions) + if err != nil { + return nil, fmt.Errorf("unable to create Kubernetes client: %w", err) + } + } + + // The cache-backed client is used exclusively with PartialObjectMetadata, + // avoiding full-object cache storage for large resources. Full Pods are + // fetched through the live client when non-metadata fields are needed. + kubeCache, err := crcache.New(restConfig, crcache.Options{ + Scheme: k8sScheme, + Mapper: clientOptions.Mapper, + HTTPClient: clientOptions.HTTPClient, + DefaultTransform: crcache.TransformStripManagedFields(), + }) + if err != nil { + return nil, fmt.Errorf("unable to create Kubernetes cache: %w", err) + } + cacheCtx, cacheCancel := context.WithCancel(context.Background()) + cacheDone := make(chan struct{}) + go func() { + defer close(cacheDone) + if err := kubeCache.Start(cacheCtx); err != nil && cacheCtx.Err() == nil { + if p.log != nil { + p.log.Warn("Kubernetes cache stopped unexpectedly", telemetry.Error, err) + } + } + }() + if !kubeCache.WaitForCacheSync(ctx) { + cacheCancel() + <-cacheDone + return nil, errors.New("timed out waiting for Kubernetes cache to start") + } + + metadataClientOptions := clientOptions + metadataClientOptions.Cache = &client.CacheOptions{ + Reader: kubeCache, + Unstructured: false, // Avoid caching unwanted large objects. + DisableFor: nil, // No point in disabling for specific types. + } + c, err := client.New(restConfig, metadataClientOptions) + if err != nil { + cacheCancel() + <-cacheDone + return nil, fmt.Errorf("unable to create Kubernetes client: %w", err) + } + + if p.kubeClient == nil { + p.kubeClient = liveClient + } + p.kubeMetadataClient = c + p.kubeCacheCancel = cacheCancel + p.kubeCacheDone = cacheDone + return c, nil +} + +func buildKubeClientOptions() (*rest.Config, client.Options, error) { + restConfig, err := ctrl.GetConfig() + if err != nil { + return nil, client.Options{}, fmt.Errorf("unable to load Kubernetes client config: %w", err) + } + httpClient, err := rest.HTTPClientFor(restConfig) + if err != nil { + return nil, client.Options{}, fmt.Errorf("unable to build Kubernetes HTTP client: %w", err) + } + mapper, err := apiutil.NewDynamicRESTMapper(restConfig, httpClient) + if err != nil { + return nil, client.Options{}, fmt.Errorf("unable to build Kubernetes REST mapper: %w", err) + } + + return restConfig, client.Options{ + Scheme: k8sScheme, + Mapper: mapper, + HTTPClient: httpClient, + }, nil +} + +func (p *Plugin) Close() error { + p.kubeMu.Lock() + cacheCancel := p.kubeCacheCancel + cacheDone := p.kubeCacheDone + p.kubeClient = nil + p.kubeMetadataClient = nil + p.kubeCacheCancel = nil + p.kubeCacheDone = nil + p.kubeMu.Unlock() + + if cacheCancel != nil { + cacheCancel() + <-cacheDone + } + return nil +} + func (p *Plugin) Configure(ctx context.Context, req *configv1.ConfigureRequest) (resp *configv1.ConfigureResponse, err error) { newConfig, _, err := pluginconf.Build(req, p.buildConfig) if err != nil { diff --git a/pkg/agent/plugin/workloadattestor/k8s/k8s_posix.go b/pkg/agent/plugin/workloadattestor/k8s/k8s_posix.go index 35510de46c..054f41bbdb 100644 --- a/pkg/agent/plugin/workloadattestor/k8s/k8s_posix.go +++ b/pkg/agent/plugin/workloadattestor/k8s/k8s_posix.go @@ -3,10 +3,8 @@ package k8s import ( - "io" "log" "os" - "path/filepath" "regexp" "strings" "unicode" @@ -57,7 +55,7 @@ func (h *containerHelper) Configure(config *HCLConfig, log hclog.Logger) error { func (h *containerHelper) GetPodUIDAndContainerID(pID int32, log hclog.Logger) (types.UID, string, error) { if !h.useNewContainerLocator { - cgroups, err := cgroups.GetCgroups(pID, dirFS(h.rootDir)) + cgroups, err := cgroups.GetCgroups(pID, os.DirFS(h.rootDir)) if err != nil { return "", "", status.Errorf(codes.Internal, "unable to obtain cgroups: %v", err) } @@ -200,9 +198,3 @@ func canonicalizePodUID(uid string) types.UID { return r }, uid)) } - -type dirFS string - -func (d dirFS) Open(p string) (io.ReadCloser, error) { - return os.Open(filepath.Join(string(d), p)) -} diff --git a/pkg/agent/plugin/workloadattestor/k8s/k8s_test.go b/pkg/agent/plugin/workloadattestor/k8s/k8s_test.go index 2517aa0192..1a3b52e140 100644 --- a/pkg/agent/plugin/workloadattestor/k8s/k8s_test.go +++ b/pkg/agent/plugin/workloadattestor/k8s/k8s_test.go @@ -19,7 +19,10 @@ import ( "testing" "time" + "github.com/spiffe/go-spiffe/v2/exp/proto/spiffe/broker" "github.com/spiffe/go-spiffe/v2/spiffeid" + workloadattestorv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/agent/workloadattestor/v1" + "github.com/spiffe/spire/pkg/agent/broker/brokercontext" "github.com/spiffe/spire/pkg/agent/common/sigstore" "github.com/spiffe/spire/pkg/agent/plugin/workloadattestor" "github.com/spiffe/spire/pkg/common/catalog" @@ -32,11 +35,23 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" + "google.golang.org/protobuf/types/known/anypb" + authv1 "k8s.io/api/authorization/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" ) const ( pid = 123 + testBrokerID = "spiffe://example.org/broker" + testPollRetryInterval = time.Second podListFilePath = "testdata/pod_list.json" @@ -47,6 +62,8 @@ const ( ) var ( + testBrokerSPIFFEID = spiffeid.RequireFromString(testBrokerID) + clientKey, _ = pemutil.ParseECPrivateKey([]byte(`-----BEGIN PRIVATE KEY----- MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgNRa/6HIy0uwQe8iG Kz24zEvwGiIsTDPHzrLUaml1hQ6hRANCAATz6vtJYIvPM0KOqKpdDPlsOw09hZ8P @@ -142,6 +159,59 @@ func (s *Suite) TestAttestWithPidInPod() { s.requireAttestSuccessWithPod(p) } +func (s *Suite) TestAttestWithPidDoesNotRunBrokerRBAC() { + s.startInsecureKubelet() + var reviews []authv1.SubjectAccessReview + p := s.loadInsecurePluginWithBrokerAndKubeClient(fakeKubeClientWithSubjectAccessReview(false, &reviews)) + + s.addPodListResponse(podListFilePath) + s.addGetContainerResponsePidInPod() + + selectors, err := p.Attest(context.Background(), pid) + s.Require().NoError(err) + s.requireSelectorsEqual(testPodAndContainerSelectors, selectors) + s.Require().Empty(reviews) +} + +func (s *Suite) TestAttestWithBrokerCallerDoesNotRunBrokerRBACWhenPermissive() { + s.startInsecureKubelet() + var reviews []authv1.SubjectAccessReview + p := s.loadRawInsecurePluginWithBrokerAndKubeClient(fakeKubeClientWithSubjectAccessReview(false, &reviews)) + + s.addPodListResponse(podListFilePath) + s.addGetContainerResponsePidInPod() + + resp, err := p.Attest(testBrokerContext(), &workloadattestorv1.AttestRequest{Pid: int32(pid)}) + s.Require().NoError(err) + s.requireSelectorValuesEqual(testPodAndContainerSelectors, resp.GetSelectorValues()) + s.Require().Empty(reviews) +} + +func (s *Suite) TestAttestWithBrokerCallerRunsBrokerRBACWhenEnforced() { + s.startInsecureKubelet() + var reviews []authv1.SubjectAccessReview + p := s.loadRawInsecurePluginWithEnforcedAccessPolicyAndKubeClient(fakeKubeClientWithSubjectAccessReview(true, &reviews)) + + s.addPodListResponse(podListFilePath) + s.addGetContainerResponsePidInPod() + + resp, err := p.Attest(testBrokerContext(), &workloadattestorv1.AttestRequest{Pid: int32(pid)}) + s.Require().NoError(err) + s.requireSelectorValuesEqual(testPodAndContainerSelectors, resp.GetSelectorValues()) + s.Require().Len(reviews, 1) + + review := reviews[0] + assert.Equal(s.T(), testBrokerID, review.Spec.User) + assert.Empty(s.T(), review.Spec.Groups) + if assert.NotNil(s.T(), review.Spec.ResourceAttributes) { + assert.Equal(s.T(), "", review.Spec.ResourceAttributes.Group) + assert.Equal(s.T(), "pods", review.Spec.ResourceAttributes.Resource) + assert.Equal(s.T(), "default", review.Spec.ResourceAttributes.Namespace) + assert.Equal(s.T(), "blog-24ck7", review.Spec.ResourceAttributes.Name) + assert.Equal(s.T(), brokerImpersonationReviewVerb, review.Spec.ResourceAttributes.Verb) + } +} + func (s *Suite) TestAttestWithPidInPodAfterRetry() { s.startInsecureKubelet() p := s.loadInsecurePlugin() @@ -343,6 +413,7 @@ func (s *Suite) TestConfigure() { PollRetryInterval time.Duration ReloadInterval time.Duration SigstoreConfig *sigstore.Config + APIServerCache bool } testCases := []struct { @@ -447,6 +518,42 @@ func (s *Suite) TestConfigure() { ReloadInterval: defaultReloadInterval, }, }, + { + name: "API server cache enabled", + trustDomain: "example.org", + hcl: ` + kubelet_read_only_port = 12345 + experimental { + api_server { + cache { + enabled = true + } + } + } + `, + config: &config{ + Insecure: true, + KubeletURL: "http://127.0.0.1:12345", + MaxPollAttempts: defaultMaxPollAttempts, + PollRetryInterval: defaultPollRetryInterval, + ReloadInterval: defaultReloadInterval, + APIServerCache: true, + }, + }, + { + name: "top-level API server cache is rejected", + trustDomain: "example.org", + hcl: ` + kubelet_read_only_port = 12345 + api_server { + cache { + enabled = true + } + } + `, + errCode: codes.InvalidArgument, + errMsg: "unknown configurations detected: api_server", + }, { name: "invalid hcl", @@ -624,6 +731,195 @@ func (s *Suite) TestConfigure() { assert.Equal(t, testCase.config.MaxPollAttempts, c.MaxPollAttempts) assert.Equal(t, testCase.config.PollRetryInterval, c.PollRetryInterval) assert.Equal(t, testCase.config.ReloadInterval, c.ReloadInterval) + assert.Equal(t, testCase.config.APIServerCache, c.APIServerCache.Enabled) + }) + } +} + +func (s *Suite) TestConfigureBroker() { + testCases := []struct { + name string + hcl string + expectedErr string + }{ + { + name: "valid broker", + hcl: fmt.Sprintf(` + kubelet_read_only_port = 12345 + %s + `, testBrokerConfig()), + }, + { + name: "top-level broker is rejected", + hcl: ` + kubelet_read_only_port = 12345 + broker { + access_policy = "permissive" + brokers = [ + { id = "spiffe://example.org/broker" } + ] + } + `, + expectedErr: "unknown configurations detected: broker", + }, + { + name: "valid pod reference scope", + hcl: ` + kubelet_read_only_port = 12345 + experimental { + broker { + access_policy = "permissive" + brokers = [ + { + id = "spiffe://example.org/broker" + pod_reference_scope = "cluster" + } + ] + } + } + `, + }, + { + name: "valid enforced access policy", + hcl: ` + kubelet_read_only_port = 12345 + experimental { + broker { + access_policy = "enforced" + brokers = [ + { + id = "spiffe://example.org/broker" + } + ] + } + } + `, + }, + { + name: "missing access policy", + hcl: ` + kubelet_read_only_port = 12345 + experimental { + broker { + brokers = [ + { id = "spiffe://example.org/broker" } + ] + } + } + `, + expectedErr: "experimental.broker.access_policy: must be specified as one of [permissive, enforced]", + }, + { + name: "invalid access policy", + hcl: ` + kubelet_read_only_port = 12345 + experimental { + broker { + access_policy = "disabled" + brokers = [ + { id = "spiffe://example.org/broker" } + ] + } + } + `, + expectedErr: `experimental.broker.access_policy: unsupported value "disabled"; must be one of [permissive, enforced]`, + }, + { + name: "empty brokers", + hcl: ` + kubelet_read_only_port = 12345 + experimental { + broker { + access_policy = "permissive" + brokers = [] + } + } + `, + expectedErr: "experimental.broker.brokers: at least one broker is required", + }, + { + name: "missing id", + hcl: ` + kubelet_read_only_port = 12345 + experimental { + broker { + access_policy = "permissive" + brokers = [ + {} + ] + } + } + `, + expectedErr: "experimental.broker.brokers[0].id: must be specified", + }, + { + name: "duplicate id", + hcl: ` + kubelet_read_only_port = 12345 + experimental { + broker { + access_policy = "permissive" + brokers = [ + { id = "spiffe://example.org/broker" }, + { id = "spiffe://example.org/broker" }, + ] + } + } + `, + expectedErr: "experimental.broker.brokers[spiffe://example.org/broker].id: duplicate broker id", + }, + { + name: "invalid id", + hcl: ` + kubelet_read_only_port = 12345 + experimental { + broker { + access_policy = "permissive" + brokers = [ + { id = "not-a-spiffe-id" } + ] + } + } + `, + expectedErr: "experimental.broker.brokers[not-a-spiffe-id].id", + }, + { + name: "invalid pod reference scope", + hcl: ` + kubelet_read_only_port = 12345 + experimental { + broker { + access_policy = "permissive" + brokers = [ + { + id = "spiffe://example.org/broker" + pod_reference_scope = "Cluster" + } + ] + } + } + `, + expectedErr: `experimental.broker.brokers[spiffe://example.org/broker].pod_reference_scope: unsupported value "Cluster"; must be one of [agent_node, cluster]`, + }, + } + + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + p := s.newPlugin() + + var err error + plugintest.Load(t, builtin(p), nil, + plugintest.CoreConfig(catalog.CoreConfig{ + TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), + }), + plugintest.Configure(tc.hcl), + plugintest.CaptureConfigureError(&err)) + + if tc.expectedErr != "" { + s.RequireGRPCStatusContains(err, codes.InvalidArgument, tc.expectedErr) + return + } + require.NoError(t, err) }) } } @@ -681,7 +977,7 @@ func (s *Suite) TestConfigureWithSigstore() { expectedError: "unable to decode configuration", }, { - name: "stale experimental block is rejected", + name: "stale experimental sigstore block is rejected", trustDomain: "example.org", hcl: ` skip_kubelet_verification = true @@ -691,7 +987,7 @@ func (s *Suite) TestConfigureWithSigstore() { } } `, - expectedError: "unknown configurations detected: experimental", + expectedError: "unknown configurations detected: sigstore", }, { name: "unknown top-level key is rejected", @@ -791,6 +1087,51 @@ func (s *Suite) loadPlugin(configuration string) workloadattestor.WorkloadAttest return v1 } +func (s *Suite) loadPluginWithKubeClient(configuration string, kubeClient client.Client) workloadattestor.WorkloadAttestor { + return s.loadPluginWithKubeClients(configuration, kubeClient, kubeClient) +} + +func (s *Suite) loadPluginWithKubeClients(configuration string, kubeClient, kubeMetadataClient client.Client) workloadattestor.WorkloadAttestor { + v1 := new(workloadattestor.V1) + p := s.newPlugin() + p.kubeClient = kubeClient + p.kubeMetadataClient = kubeMetadataClient + + plugintest.Load(s.T(), builtin(p), v1, + plugintest.CoreConfig(catalog.CoreConfig{ + TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), + }), + plugintest.Configure(configuration), + ) + + if cHelper := s.oc.getContainerHelper(p); cHelper != nil { + p.setContainerHelper(cHelper) + } + return v1 +} + +func (s *Suite) loadRawPluginWithKubeClient(configuration string, kubeClient client.Client) *Plugin { + return s.loadRawPluginWithKubeClients(configuration, kubeClient, kubeClient) +} + +func (s *Suite) loadRawPluginWithKubeClients(configuration string, kubeClient, kubeMetadataClient client.Client) *Plugin { + p := s.newPlugin() + p.kubeClient = kubeClient + p.kubeMetadataClient = kubeMetadataClient + + plugintest.Load(s.T(), builtin(p), nil, + plugintest.CoreConfig(catalog.CoreConfig{ + TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), + }), + plugintest.Configure(configuration), + ) + + if cHelper := s.oc.getContainerHelper(p); cHelper != nil { + p.setContainerHelper(cHelper) + } + return p +} + func (s *Suite) loadInsecurePlugin() workloadattestor.WorkloadAttestor { return s.loadPlugin(fmt.Sprintf(` kubelet_read_only_port = %d @@ -799,6 +1140,46 @@ func (s *Suite) loadInsecurePlugin() workloadattestor.WorkloadAttestor { `, s.kubeletPort())) } +func (s *Suite) loadInsecurePluginWithBroker() workloadattestor.WorkloadAttestor { + return s.loadInsecurePluginWithExtra(testBrokerConfig()) +} + +func (s *Suite) loadInsecurePluginWithBrokerAndKubeClient(kubeClient client.Client) workloadattestor.WorkloadAttestor { + return s.loadPluginWithKubeClient(fmt.Sprintf(` + kubelet_read_only_port = %d + max_poll_attempts = 5 + poll_retry_interval = "1s" + %s +`, s.kubeletPort(), testBrokerConfig()), kubeClient) +} + +func (s *Suite) loadInsecurePluginWithEnforcedAccessPolicyAndKubeClient(kubeClient client.Client) workloadattestor.WorkloadAttestor { + return s.loadPluginWithKubeClient(fmt.Sprintf(` + kubelet_read_only_port = %d + max_poll_attempts = 5 + poll_retry_interval = "1s" + %s +`, s.kubeletPort(), testBrokerConfigWithEnforcedAccessPolicy()), kubeClient) +} + +func (s *Suite) loadRawInsecurePluginWithBrokerAndKubeClient(kubeClient client.Client) *Plugin { + return s.loadRawPluginWithKubeClient(fmt.Sprintf(` + kubelet_read_only_port = %d + max_poll_attempts = 5 + poll_retry_interval = "1s" + %s +`, s.kubeletPort(), testBrokerConfig()), kubeClient) +} + +func (s *Suite) loadRawInsecurePluginWithEnforcedAccessPolicyAndKubeClient(kubeClient client.Client) *Plugin { + return s.loadRawPluginWithKubeClient(fmt.Sprintf(` + kubelet_read_only_port = %d + max_poll_attempts = 5 + poll_retry_interval = "1s" + %s +`, s.kubeletPort(), testBrokerConfigWithEnforcedAccessPolicy()), kubeClient) +} + func (s *Suite) loadInsecurePluginWithExtra(extraConfig string) workloadattestor.WorkloadAttestor { return s.loadPlugin(fmt.Sprintf(` kubelet_read_only_port = %d @@ -818,6 +1199,121 @@ func (s *Suite) loadInsecurePluginWithSigstore() workloadattestor.WorkloadAttest `, s.kubeletPort())) } +func testBrokerConfig() string { + return testBrokerConfigWithAccessPolicy("", string(brokerAccessPolicyPermissive)) +} + +func testBrokerConfigWithEnforcedAccessPolicy() string { + return testBrokerConfigWithAccessPolicy("", string(brokerAccessPolicyEnforced)) +} + +func testBrokerConfigWithPodReferenceScope(scope string) string { + return testBrokerConfigWithAccessPolicy(scope, string(brokerAccessPolicyPermissive)) +} + +func testBrokerConfigWithAccessPolicy(scope, accessPolicy string) string { + var scopeConfig string + if scope != "" { + scopeConfig = fmt.Sprintf("\n\t\t\t\t\t\tpod_reference_scope = %q", scope) + } + return fmt.Sprintf(` + experimental { + broker { + access_policy = %q + brokers = [ + { + id = %q%s + } + ] + } + } +`, accessPolicy, testBrokerID, scopeConfig) +} + +func testBrokerContext() context.Context { + return brokercontext.WithCallerID(context.Background(), testBrokerSPIFFEID) +} + +func fakeKubeClientWithSubjectAccessReview(allowed bool, reviews *[]authv1.SubjectAccessReview, objects ...client.Object) client.Client { + return fakeKubeClientWithSubjectAccessReviewAndRESTMapper(allowed, reviews, nil, objects...) +} + +func fakeKubeClientWithSubjectAccessReviewAndRESTMapper(allowed bool, reviews *[]authv1.SubjectAccessReview, mapper meta.RESTMapper, objects ...client.Object) client.Client { + return fakeKubeClientWithSubjectAccessReviewAndRESTMapperAndInterceptors(allowed, reviews, mapper, interceptor.Funcs{}, objects...) +} + +func fakeKubeClientWithSubjectAccessReviewAndRESTMapperAndInterceptors(allowed bool, reviews *[]authv1.SubjectAccessReview, mapper meta.RESTMapper, funcs interceptor.Funcs, objects ...client.Object) client.Client { + create := funcs.Create + funcs.Create = func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.CreateOption) error { + review, ok := obj.(*authv1.SubjectAccessReview) + if !ok { + if create != nil { + return create(ctx, c, obj, opts...) + } + return c.Create(ctx, obj, opts...) + } + if reviews != nil { + *reviews = append(*reviews, *review.DeepCopy()) + } + review.Status.Allowed = allowed + return nil + } + + builder := fake.NewClientBuilder(). + WithScheme(k8sScheme). + WithObjects(objects...). + WithInterceptorFuncs(funcs) + if mapper != nil { + builder = builder.WithRESTMapper(mapper) + } + return builder.Build() +} + +func fakeKubeMetadataClient(objects ...*metav1.PartialObjectMetadata) client.Client { + return fakeKubeMetadataClientWithListHook(nil, objects...) +} + +func fakeKubeMetadataClientWithListHook(hook func(client.ObjectList, ...client.ListOption), objects ...*metav1.PartialObjectMetadata) client.Client { + return fake.NewClientBuilder(). + WithScheme(k8sScheme). + WithInterceptorFuncs(interceptor.Funcs{ + List: func(ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { + metadataList, ok := list.(*metav1.PartialObjectMetadataList) + if !ok { + return c.List(ctx, list, opts...) + } + if hook != nil { + hook(list, opts...) + } + + listOptions := &client.ListOptions{} + for _, opt := range opts { + opt.ApplyToList(listOptions) + } + + listGVK := metadataList.GetObjectKind().GroupVersionKind() + itemKind := listGVK.Kind + if len(itemKind) > len("List") && itemKind[len(itemKind)-len("List"):] == "List" { + itemKind = itemKind[:len(itemKind)-len("List")] + } + itemGVK := schema.GroupVersionKind{Group: listGVK.Group, Version: listGVK.Version, Kind: itemKind} + + metadataList.Items = nil + for _, obj := range objects { + if obj.GetObjectKind().GroupVersionKind() != itemGVK { + continue + } + if listOptions.Namespace != "" && obj.Namespace != listOptions.Namespace { + continue + } + metadataList.Items = append(metadataList.Items, *obj.DeepCopy()) + } + return nil + }, + }). + Build() +} + func (s *Suite) startInsecureKubelet() { s.setServer(httptest.NewServer(http.HandlerFunc(s.serveHTTP))) } @@ -983,6 +1479,14 @@ func (s *Suite) requireSelectorsEqual(expected, actual []*common.Selector) { s.RequireProtoListEqual(expected, actual) } +func (s *Suite) requireSelectorValuesEqual(expected []*common.Selector, actual []string) { + selectors := make([]*common.Selector, 0, len(actual)) + for _, value := range actual { + selectors = append(selectors, &common.Selector{Type: pluginName, Value: value}) + } + s.requireSelectorsEqual(expected, selectors) +} + func (s *Suite) goAttest(p workloadattestor.WorkloadAttestor) <-chan attestResult { resultCh := make(chan attestResult, 1) go func() { @@ -1021,6 +1525,520 @@ func (s *Suite) podListResponseCount() int { return len(s.podList) } +// testPodUID is the UID of the blog pod in testdata/pod_list.json. +const testPodUID = "2c48913c-b29f-11e7-9350-020968147796" + +func testAPIServerBlogPod() *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "blog-24ck7", + Namespace: "default", + UID: types.UID(testPodUID), + Labels: map[string]string{ + "k8s-app": "blog", + "version": "v0", + }, + OwnerReferences: []metav1.OwnerReference{ + {Kind: "ReplicationController", Name: "blog", UID: "2c401175-b29f-11e7-9350-020968147796"}, + }, + }, + Spec: corev1.PodSpec{ + NodeName: "k8s-node-1", + ServiceAccountName: "default", + }, + Status: corev1.PodStatus{ + ContainerStatuses: []corev1.ContainerStatus{ + { + Image: "localhost/spiffe/blog:latest", + ImageID: "docker-pullable://localhost/spiffe/blog@sha256:0cfdaced91cb46dd7af48309799a3c351e4ca2d5e1ee9737ca0cbd932cb79898", + }, + { + Image: "localhost/spiffe/ghostunnel:latest", + ImageID: "docker-pullable://localhost/spiffe/ghostunnel@sha256:b2fc20676c92a433b9a91f3f4535faddec0c2c3613849ac12f02c1d5cfcd4c3a", + }, + }, + }, + } +} + +func testAPIServerBlogPodMetadata() *metav1.PartialObjectMetadata { + pod := testAPIServerBlogPod() + obj := &metav1.PartialObjectMetadata{ObjectMeta: pod.ObjectMeta} + obj.SetGroupVersionKind(schema.GroupVersionKind{Version: "v1", Kind: "Pod"}) + return obj +} + +func (s *Suite) TestAttestReferenceWithPIDSkipsBrokerRBACWhenPermissive() { + s.startInsecureKubelet() + var reviews []authv1.SubjectAccessReview + p := s.loadInsecurePluginWithBrokerAndKubeClient(fakeKubeClientWithSubjectAccessReview(false, &reviews)) + + s.addPodListResponse(podListFilePath) + s.addGetContainerResponsePidInPod() + + anyRef, err := anypb.New(&broker.WorkloadPIDReference{Pid: int32(pid)}) + s.Require().NoError(err) + + selectors, err := p.AttestReference(testBrokerContext(), anyRef) + s.Require().NoError(err) + s.requireSelectorsEqual(testPodAndContainerSelectors, selectors) + s.Require().Empty(reviews) +} + +func (s *Suite) TestAttestReferenceWithPIDBrokerRBACAllowedWhenEnforced() { + s.startInsecureKubelet() + var reviews []authv1.SubjectAccessReview + p := s.loadInsecurePluginWithEnforcedAccessPolicyAndKubeClient(fakeKubeClientWithSubjectAccessReview(true, &reviews)) + + s.addPodListResponse(podListFilePath) + s.addGetContainerResponsePidInPod() + + anyRef, err := anypb.New(&broker.WorkloadPIDReference{Pid: int32(pid)}) + s.Require().NoError(err) + + selectors, err := p.AttestReference(testBrokerContext(), anyRef) + s.Require().NoError(err) + s.requireSelectorsEqual(testPodAndContainerSelectors, selectors) + s.Require().Len(reviews, 1) + + review := reviews[0] + assert.Equal(s.T(), testBrokerID, review.Spec.User) + assert.Empty(s.T(), review.Spec.Groups) + if assert.NotNil(s.T(), review.Spec.ResourceAttributes) { + assert.Equal(s.T(), "", review.Spec.ResourceAttributes.Group) + assert.Equal(s.T(), "pods", review.Spec.ResourceAttributes.Resource) + assert.Equal(s.T(), "default", review.Spec.ResourceAttributes.Namespace) + assert.Equal(s.T(), "blog-24ck7", review.Spec.ResourceAttributes.Name) + assert.Equal(s.T(), brokerImpersonationReviewVerb, review.Spec.ResourceAttributes.Verb) + } +} + +func (s *Suite) TestAttestReferenceWithPIDBrokerRBACDenied() { + s.startInsecureKubelet() + p := s.loadInsecurePluginWithEnforcedAccessPolicyAndKubeClient(fakeKubeClientWithSubjectAccessReview(false, nil)) + + s.addPodListResponse(podListFilePath) + s.addGetContainerResponsePidInPod() + + anyRef, err := anypb.New(&broker.WorkloadPIDReference{Pid: int32(pid)}) + s.Require().NoError(err) + + selectors, err := p.AttestReference(testBrokerContext(), anyRef) + s.RequireGRPCStatusContains(err, codes.PermissionDenied, "Kubernetes authorizer does not allow the broker to use impersonate-via-spire for the referenced object") + s.Require().Nil(selectors) +} + +func (s *Suite) TestAttestReferenceWithPodUID_FoundInKubelet() { + s.startInsecureKubelet() + p := s.loadInsecurePluginWithBrokerAndKubeClient(fakeKubeClientWithSubjectAccessReview(true, nil)) + + s.addPodListResponse(podListFilePath) + + anyRef, err := anypb.New(&broker.KubernetesObjectReference{Type: &broker.KubernetesObjectType{Plural: "pods", Group: "core"}, Uid: testPodUID}) + s.Require().NoError(err) + + selectors, err := p.AttestReference(testBrokerContext(), anyRef) + s.Require().NoError(err) + s.requireSelectorsEqual(testPodSelectors, selectors) +} + +func (s *Suite) TestAttestReferenceWithPodUID_DefaultScopeDoesNotCallAPIServer() { + s.startInsecureKubelet() + + // Serve an empty pod list so the kubelet lookup finds nothing. + emptyPodList := []byte(`{"items":[]}`) + s.podListMu.Lock() + s.podList = append(s.podList, emptyPodList) + s.podListMu.Unlock() + + var reviews []authv1.SubjectAccessReview + var metadataPodLists int + var livePodGets int + var livePodLists int + liveClient := fakeKubeClientWithSubjectAccessReviewAndRESTMapperAndInterceptors(true, &reviews, nil, interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*corev1.Pod); ok { + livePodGets++ + } + return c.Get(ctx, key, obj, opts...) + }, + List: func(ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { + if _, ok := list.(*corev1.PodList); ok { + livePodLists++ + } + return c.List(ctx, list, opts...) + }, + }, testAPIServerBlogPod()) + metadataClient := fakeKubeMetadataClientWithListHook(func(list client.ObjectList, opts ...client.ListOption) { + if _, ok := list.(*metav1.PartialObjectMetadataList); ok { + metadataPodLists++ + } + }, testAPIServerBlogPodMetadata()) + cfg := fmt.Sprintf(` + kubelet_read_only_port = %d + max_poll_attempts = 5 + poll_retry_interval = "1s" + %s +`, s.kubeletPort(), testBrokerConfig()) + wa := s.loadPluginWithKubeClients(cfg, liveClient, metadataClient) + + anyRef, err := anypb.New(&broker.KubernetesObjectReference{Type: &broker.KubernetesObjectType{Plural: "pods", Group: "core"}, Uid: testPodUID}) + s.Require().NoError(err) + + selectors, err := wa.AttestReference(testBrokerContext(), anyRef) + s.RequireGRPCStatusContains(err, codes.NotFound, "pod with UID 2c48913c-b29f-11e7-9350-020968147796 not found on agent node") + s.Require().Nil(selectors) + assert.Zero(s.T(), metadataPodLists) + assert.Zero(s.T(), livePodGets) + assert.Zero(s.T(), livePodLists) + s.Require().Empty(reviews) +} + +func (s *Suite) TestAttestReferenceWithPodName_DefaultScopeDoesNotCallAPIServer() { + s.startInsecureKubelet() + + // Serve an empty pod list so the kubelet lookup finds nothing. + emptyPodList := []byte(`{"items":[]}`) + s.podListMu.Lock() + s.podList = append(s.podList, emptyPodList) + s.podListMu.Unlock() + + var reviews []authv1.SubjectAccessReview + var livePodGets int + var livePodLists int + liveClient := fakeKubeClientWithSubjectAccessReviewAndRESTMapperAndInterceptors(true, &reviews, nil, interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*corev1.Pod); ok { + livePodGets++ + } + return c.Get(ctx, key, obj, opts...) + }, + List: func(ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { + if _, ok := list.(*corev1.PodList); ok { + livePodLists++ + } + return c.List(ctx, list, opts...) + }, + }, testAPIServerBlogPod()) + cfg := fmt.Sprintf(` + kubelet_read_only_port = %d + max_poll_attempts = 5 + poll_retry_interval = "1s" + %s +`, s.kubeletPort(), testBrokerConfig()) + wa := s.loadPluginWithKubeClient(cfg, liveClient) + + anyRef, err := anypb.New(&broker.KubernetesObjectReference{ + Type: &broker.KubernetesObjectType{Plural: "pods", Group: "core"}, + Key: &broker.KubernetesObjectKey{Namespace: "default", Name: "blog-24ck7"}, + }) + s.Require().NoError(err) + + selectors, err := wa.AttestReference(testBrokerContext(), anyRef) + s.RequireGRPCStatusContains(err, codes.NotFound, "pod default/blog-24ck7 not found on agent node") + s.Require().Nil(selectors) + assert.Zero(s.T(), livePodGets) + assert.Zero(s.T(), livePodLists) + s.Require().Empty(reviews) +} + +func (s *Suite) TestAttestReferenceWithPodUID_FallbackToAPIServerWithClusterScope() { + s.startInsecureKubelet() + + // Serve an empty pod list so the kubelet lookup finds nothing. + emptyPodList := []byte(`{"items":[]}`) + s.podListMu.Lock() + s.podList = append(s.podList, emptyPodList) + s.podListMu.Unlock() + + liveClient := fakeKubeClientWithSubjectAccessReview(true, nil, testAPIServerBlogPod()) + metadataClient := fakeKubeMetadataClient(testAPIServerBlogPodMetadata()) + cfg := fmt.Sprintf(` + kubelet_read_only_port = %d + max_poll_attempts = 5 + poll_retry_interval = "1s" + %s +`, s.kubeletPort(), testBrokerConfigWithPodReferenceScope("cluster")) + wa := s.loadPluginWithKubeClients(cfg, liveClient, metadataClient) + + anyRef, err := anypb.New(&broker.KubernetesObjectReference{Type: &broker.KubernetesObjectType{Plural: "pods", Group: "core"}, Uid: testPodUID}) + s.Require().NoError(err) + + selectors, err := wa.AttestReference(testBrokerContext(), anyRef) + s.Require().NoError(err) + s.requireSelectorsEqual(testPodSelectors, selectors) +} + +func (s *Suite) TestAttestReferenceWithPodName_FallbackToAPIServerWithClusterScope() { + s.startInsecureKubelet() + + // Serve an empty pod list so the kubelet lookup finds nothing. + emptyPodList := []byte(`{"items":[]}`) + s.podListMu.Lock() + s.podList = append(s.podList, emptyPodList) + s.podListMu.Unlock() + + liveClient := fakeKubeClientWithSubjectAccessReview(true, nil, testAPIServerBlogPod()) + cfg := fmt.Sprintf(` + kubelet_read_only_port = %d + max_poll_attempts = 5 + poll_retry_interval = "1s" + %s +`, s.kubeletPort(), testBrokerConfigWithPodReferenceScope("cluster")) + wa := s.loadPluginWithKubeClient(cfg, liveClient) + + anyRef, err := anypb.New(&broker.KubernetesObjectReference{ + Type: &broker.KubernetesObjectType{Plural: "pods", Group: "core"}, + Key: &broker.KubernetesObjectKey{Namespace: "default", Name: "blog-24ck7"}, + }) + s.Require().NoError(err) + + selectors, err := wa.AttestReference(testBrokerContext(), anyRef) + s.Require().NoError(err) + s.requireSelectorsEqual(testPodSelectors, selectors) +} + +func (s *Suite) TestAttestReferenceWithPodUID_NotFound() { + s.startInsecureKubelet() + + emptyPodList := []byte(`{"items":[]}`) + s.podListMu.Lock() + s.podList = append(s.podList, emptyPodList) + s.podListMu.Unlock() + + liveClient := fakeKubeClientWithSubjectAccessReview(true, nil) + metadataClient := fakeKubeMetadataClient() + cfg := fmt.Sprintf(` + kubelet_read_only_port = %d + max_poll_attempts = 5 + poll_retry_interval = "1s" + %s +`, s.kubeletPort(), testBrokerConfig()) + wa := s.loadPluginWithKubeClients(cfg, liveClient, metadataClient) + + anyRef, err := anypb.New(&broker.KubernetesObjectReference{Type: &broker.KubernetesObjectType{Plural: "pods", Group: "core"}, Uid: "nonexistent-uid"}) + s.Require().NoError(err) + + selectors, err := wa.AttestReference(testBrokerContext(), anyRef) + s.RequireGRPCStatusContains(err, codes.NotFound, "not found") + s.Require().Nil(selectors) +} + +func (s *Suite) TestAttestReferenceUnsupportedType() { + s.startInsecureKubelet() + p := s.loadInsecurePluginWithBroker() + + anyRef := &anypb.Any{TypeUrl: "type.googleapis.com/unsupported.Type", Value: []byte{}} + + selectors, err := p.AttestReference(testBrokerContext(), anyRef) + s.RequireGRPCStatusContains(err, codes.InvalidArgument, "unsupported reference type") + s.Require().Nil(selectors) +} + +func (s *Suite) TestAttestReferenceKubernetesObjectValidation() { + s.startInsecureKubelet() + p := s.loadInsecurePluginWithBrokerAndKubeClient(fakeKubeClientWithSubjectAccessReview(true, nil)) + + testCases := []struct { + name string + ref *broker.KubernetesObjectReference + errMsg string + }{ + { + name: "missing type", + ref: &broker.KubernetesObjectReference{Uid: testPodUID}, + errMsg: "object reference is missing type", + }, + { + name: "missing plural", + ref: &broker.KubernetesObjectReference{Type: &broker.KubernetesObjectType{Group: "core"}, Uid: testPodUID}, + errMsg: "object reference type is missing plural", + }, + { + name: "missing group", + ref: &broker.KubernetesObjectReference{Type: &broker.KubernetesObjectType{Plural: "pods"}, Uid: testPodUID}, + errMsg: "object reference type is missing group", + }, + { + name: "missing key and uid", + ref: &broker.KubernetesObjectReference{Type: &broker.KubernetesObjectType{Plural: "pods", Group: "core"}}, + errMsg: "object reference is missing key and UID", + }, + { + name: "key missing name", + ref: &broker.KubernetesObjectReference{ + Type: &broker.KubernetesObjectType{Plural: "pods", Group: "core"}, + Key: &broker.KubernetesObjectKey{Namespace: "shop"}, + }, + errMsg: "object reference key is missing name", + }, + { + name: "pod key missing namespace", + ref: &broker.KubernetesObjectReference{ + Type: &broker.KubernetesObjectType{Plural: "pods", Group: "core"}, + Key: &broker.KubernetesObjectKey{Name: "checkout"}, + }, + errMsg: "namespace is required when name is set for a namespaced resource", + }, + } + + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + anyRef, err := anypb.New(tc.ref) + s.Require().NoError(err) + + selectors, err := p.AttestReference(testBrokerContext(), anyRef) + s.RequireGRPCStatusContains(err, codes.InvalidArgument, tc.errMsg) + s.Require().Nil(selectors) + }) + } +} + +func (s *Suite) TestAttestReferenceRequiresBrokerConfig() { + s.startInsecureKubelet() + p := s.loadInsecurePlugin() + + anyRef, err := anypb.New(&broker.KubernetesObjectReference{Type: &broker.KubernetesObjectType{Plural: "pods", Group: "core"}, Uid: testPodUID}) + s.Require().NoError(err) + + selectors, err := p.AttestReference(testBrokerContext(), anyRef) + s.RequireGRPCStatusContains(err, codes.Internal, "broker configuration missing") + s.Require().Nil(selectors) +} + +func (s *Suite) TestAttestReferenceWithoutBrokerCallerDoesNotRequireBrokerConfig() { + s.startInsecureKubelet() + p := s.loadInsecurePlugin() + s.addPodListResponse(podListFilePath) + + anyRef, err := anypb.New(&broker.KubernetesObjectReference{Type: &broker.KubernetesObjectType{Plural: "pods", Group: "core"}, Uid: testPodUID}) + s.Require().NoError(err) + + selectors, err := p.AttestReference(context.Background(), anyRef) + s.Require().NoError(err) + s.requireSelectorsEqual(testPodSelectors, selectors) +} + +func (s *Suite) TestAttestReferenceWithoutBrokerCallerDoesNotRunBrokerRBAC() { + s.startInsecureKubelet() + var reviews []authv1.SubjectAccessReview + p := s.loadInsecurePluginWithBrokerAndKubeClient(fakeKubeClientWithSubjectAccessReview(false, &reviews)) + s.addPodListResponse(podListFilePath) + + anyRef, err := anypb.New(&broker.KubernetesObjectReference{Type: &broker.KubernetesObjectType{Plural: "pods", Group: "core"}, Uid: testPodUID}) + s.Require().NoError(err) + + selectors, err := p.AttestReference(context.Background(), anyRef) + s.Require().NoError(err) + s.requireSelectorsEqual(testPodSelectors, selectors) + s.Require().Empty(reviews) +} + +func (s *Suite) TestAttestReferenceBrokerRBACDenied() { + s.startInsecureKubelet() + p := s.loadInsecurePluginWithEnforcedAccessPolicyAndKubeClient(fakeKubeClientWithSubjectAccessReview(false, nil)) + s.addPodListResponse(podListFilePath) + + anyRef, err := anypb.New(&broker.KubernetesObjectReference{Type: &broker.KubernetesObjectType{Plural: "pods", Group: "core"}, Uid: testPodUID}) + s.Require().NoError(err) + + selectors, err := p.AttestReference(testBrokerContext(), anyRef) + s.RequireGRPCStatusContains(err, codes.PermissionDenied, "Kubernetes authorizer does not allow the broker to use impersonate-via-spire for the referenced object") + s.Require().Nil(selectors) +} + +func (s *Suite) TestAttestReferenceBrokerRBACUsesResolvedObject() { + s.startInsecureKubelet() + var reviews []authv1.SubjectAccessReview + p := s.loadInsecurePluginWithEnforcedAccessPolicyAndKubeClient(fakeKubeClientWithSubjectAccessReview(true, &reviews)) + s.addPodListResponse(podListFilePath) + + anyRef, err := anypb.New(&broker.KubernetesObjectReference{Type: &broker.KubernetesObjectType{Plural: "pods", Group: "core"}, Uid: testPodUID}) + s.Require().NoError(err) + + selectors, err := p.AttestReference(testBrokerContext(), anyRef) + s.Require().NoError(err) + s.requireSelectorsEqual(testPodSelectors, selectors) + s.Require().Len(reviews, 1) + + review := reviews[0] + assert.Equal(s.T(), testBrokerID, review.Spec.User) + assert.Empty(s.T(), review.Spec.Groups) + if assert.NotNil(s.T(), review.Spec.ResourceAttributes) { + assert.Equal(s.T(), "", review.Spec.ResourceAttributes.Group) + assert.Equal(s.T(), "pods", review.Spec.ResourceAttributes.Resource) + assert.Equal(s.T(), "default", review.Spec.ResourceAttributes.Namespace) + assert.Equal(s.T(), "blog-24ck7", review.Spec.ResourceAttributes.Name) + assert.Equal(s.T(), brokerImpersonationReviewVerb, review.Spec.ResourceAttributes.Verb) + } +} + +func (s *Suite) TestAttestReferenceBrokerRBACUsesResolvedGenericObject() { + s.startInsecureKubelet() + + gvk := schema.GroupVersionKind{ + Group: "kustomize.toolkit.fluxcd.io", + Version: "v1", + Kind: "Kustomization", + } + gvr := schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: "kustomizations", + } + mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{gvk.GroupVersion()}) + mapper.AddSpecific(gvk, gvr, schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: "kustomization", + }, meta.RESTScopeNamespace) + + obj := &metav1.PartialObjectMetadata{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tenant-a", + Namespace: "flux-system", + UID: "kustomization-uid", + }, + } + obj.SetGroupVersionKind(gvk) + + var reviews []authv1.SubjectAccessReview + p := s.loadInsecurePluginWithEnforcedAccessPolicyAndKubeClient(fakeKubeClientWithSubjectAccessReviewAndRESTMapper(true, &reviews, mapper, obj)) + + anyRef, err := anypb.New(&broker.KubernetesObjectReference{ + Type: &broker.KubernetesObjectType{ + Plural: "kustomizations", + Group: "kustomize.toolkit.fluxcd.io", + }, + Uid: "kustomization-uid", + }) + s.Require().NoError(err) + + selectors, err := p.AttestReference(testBrokerContext(), anyRef) + s.Require().NoError(err) + s.Require().Len(reviews, 1) + + selectorValues := make([]string, 0, len(selectors)) + for _, selector := range selectors { + assert.Equal(s.T(), "k8s", selector.Type) + selectorValues = append(selectorValues, selector.Value) + } + assert.Contains(s.T(), selectorValues, "uid:kustomization-uid") + assert.Contains(s.T(), selectorValues, "namespace:flux-system") + assert.Contains(s.T(), selectorValues, "name:tenant-a") + + review := reviews[0] + assert.Equal(s.T(), testBrokerID, review.Spec.User) + assert.Empty(s.T(), review.Spec.Groups) + if assert.NotNil(s.T(), review.Spec.ResourceAttributes) { + assert.Equal(s.T(), "kustomize.toolkit.fluxcd.io", review.Spec.ResourceAttributes.Group) + assert.Equal(s.T(), "kustomizations", review.Spec.ResourceAttributes.Resource) + assert.Equal(s.T(), "flux-system", review.Spec.ResourceAttributes.Namespace) + assert.Equal(s.T(), "tenant-a", review.Spec.ResourceAttributes.Name) + assert.Equal(s.T(), brokerImpersonationReviewVerb, review.Spec.ResourceAttributes.Verb) + } +} + type fakeSigstoreVerifier struct { mu sync.Mutex @@ -1043,3 +2061,81 @@ func (v *fakeSigstoreVerifier) Verify(_ context.Context, imageID string) ([]stri return nil, fmt.Errorf("failed to verify signature for image %s", imageID) } + +func TestGetSelectorValuesFromObjectMeta(t *testing.T) { + truePtr := true + falsePtr := false + + for _, tc := range []struct { + name string + objType *broker.KubernetesObjectType + gvk schema.GroupVersionKind + obj *metav1.PartialObjectMetadata + expected []string + }{ + { + name: "namespaced with all fields", + objType: &broker.KubernetesObjectType{Plural: "deployments", Group: "apps"}, + gvk: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, + obj: &metav1.PartialObjectMetadata{ + ObjectMeta: metav1.ObjectMeta{ + Name: "checkout", + Namespace: "shop", + UID: "a1b2c3", + Labels: map[string]string{"app": "checkout"}, + Annotations: map[string]string{"team": "payments"}, // ignored — annotations are not used as selectors + OwnerReferences: []metav1.OwnerReference{ + {APIVersion: "apps/v1", Kind: "ReplicaSet", Name: "checkout-rs", UID: "owner-uid-1", Controller: &truePtr}, + {APIVersion: "v1", Kind: "ConfigMap", Name: "checkout-cm", UID: "owner-uid-2", Controller: &falsePtr}, + }, + }, + }, + expected: []string{ + "uid:a1b2c3", + "resource:deployments.apps", + "plural:deployments", + "group:apps", + "version:v1", + "apiVersion:apps/v1", + "kind:Deployment", + "name:checkout", + "namespace:shop", + "key:shop/checkout", + "label:app:checkout", + "owner-key:apps/ReplicaSet/checkout-rs", + "owner-uid:apps/ReplicaSet/owner-uid-1", + "controller-key:apps/ReplicaSet/checkout-rs", + "controller-uid:apps/ReplicaSet/owner-uid-1", + "owner-key:core/ConfigMap/checkout-cm", + "owner-uid:core/ConfigMap/owner-uid-2", + }, + }, + { + name: "cluster-scoped, no owners/labels", + objType: &broker.KubernetesObjectType{Plural: "nodes", Group: "core"}, + gvk: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"}, + obj: &metav1.PartialObjectMetadata{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ip-10-0-1-42.ec2.internal", + UID: "node-uid", + }, + }, + expected: []string{ + "uid:node-uid", + "resource:nodes.core", + "plural:nodes", + "group:core", + "version:v1", + "apiVersion:v1", + "kind:Node", + "name:ip-10-0-1-42.ec2.internal", + "key:ip-10-0-1-42.ec2.internal", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := getSelectorValuesFromObjectMeta(tc.objType, tc.gvk, tc.obj) + assert.ElementsMatch(t, tc.expected, got) + }) + } +} diff --git a/pkg/agent/plugin/workloadattestor/systemd/systemd_posix.go b/pkg/agent/plugin/workloadattestor/systemd/systemd_posix.go index 4fbfeef9dc..e2d017c463 100644 --- a/pkg/agent/plugin/workloadattestor/systemd/systemd_posix.go +++ b/pkg/agent/plugin/workloadattestor/systemd/systemd_posix.go @@ -75,6 +75,13 @@ func (p *Plugin) Attest(ctx context.Context, req *workloadattestorv1.AttestReque }, nil } +// AttestReference returns Unimplemented. This plugin does not handle +// reference-based workload attestation; the host falls back to PID-based +// Attest when the reference is a WorkloadPIDReference. +func (p *Plugin) AttestReference(_ context.Context, _ *workloadattestorv1.AttestReferenceRequest) (*workloadattestorv1.AttestReferenceResponse, error) { + return nil, status.Error(codes.Unimplemented, "AttestReference not implemented") +} + func (p *Plugin) Close() error { p.dbusMutex.Lock() defer p.dbusMutex.Unlock() diff --git a/pkg/agent/plugin/workloadattestor/unix/unix_posix.go b/pkg/agent/plugin/workloadattestor/unix/unix_posix.go index 5f13b3e218..da5599fa6a 100644 --- a/pkg/agent/plugin/workloadattestor/unix/unix_posix.go +++ b/pkg/agent/plugin/workloadattestor/unix/unix_posix.go @@ -205,6 +205,13 @@ func (p *Plugin) Attest(_ context.Context, req *workloadattestorv1.AttestRequest }, nil } +// AttestReference returns Unimplemented. This plugin does not handle +// reference-based workload attestation; the host falls back to PID-based +// Attest when the reference is a WorkloadPIDReference. +func (p *Plugin) AttestReference(_ context.Context, _ *workloadattestorv1.AttestReferenceRequest) (*workloadattestorv1.AttestReferenceResponse, error) { + return nil, status.Error(codes.Unimplemented, "AttestReference not implemented") +} + func (p *Plugin) Configure(_ context.Context, req *configv1.ConfigureRequest) (*configv1.ConfigureResponse, error) { newConfig, _, err := pluginconf.Build(req, buildConfig) if err != nil { diff --git a/pkg/agent/plugin/workloadattestor/v1.go b/pkg/agent/plugin/workloadattestor/v1.go index 329a7a17de..f804c80cea 100644 --- a/pkg/agent/plugin/workloadattestor/v1.go +++ b/pkg/agent/plugin/workloadattestor/v1.go @@ -2,12 +2,19 @@ package workloadattestor import ( "context" + "errors" "fmt" + "github.com/spiffe/go-spiffe/v2/exp/proto/spiffe/broker" workloadattestorv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/agent/workloadattestor/v1" + "github.com/spiffe/spire/pkg/agent/broker/brokercontext" "github.com/spiffe/spire/pkg/common/plugin" "github.com/spiffe/spire/pkg/common/util" "github.com/spiffe/spire/proto/spire/common" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" ) type V1 struct { @@ -15,27 +22,98 @@ type V1 struct { workloadattestorv1.WorkloadAttestorPluginClient } +// Attest invokes the plugin's PID-based Attest RPC. Older plugins implement +// only this RPC; newer plugins may instead implement only AttestReference. +// To bridge both styles, if Attest returns Unimplemented, we pack the PID as +// a WorkloadPIDReference and retry through AttestReference. func (v1 *V1) Attest(ctx context.Context, pid int) ([]*common.Selector, error) { pidInt32, err := util.CheckedCast[int32](pid) if err != nil { return nil, v1.WrapErr(fmt.Errorf("invalid value for PID: %w", err)) } + + selectors, err := v1.attestByPID(ctx, pidInt32) + if status.Code(err) != codes.Unimplemented { + return selectors, err + } + + // Plugin doesn't implement Attest; try AttestReference with a packed + // WorkloadPIDReference. We call the raw method directly to avoid an + // infinite loop: if AttestReference is also unimplemented we surface the + // original Unimplemented to the caller. + ref, packErr := anypb.New(&broker.WorkloadPIDReference{Pid: pidInt32}) + if packErr != nil { + return nil, v1.WrapErr(packErr) + } + return v1.attestByReference(ctx, ref) +} + +// AttestReference invokes the plugin's reference-based AttestReference RPC. +// If the plugin returns Unimplemented and the supplied reference is a +// WorkloadPIDReference, we fall back to the legacy PID-based Attest. Other +// reference types surface the Unimplemented error directly because there's +// no equivalent legacy path. +func (v1 *V1) AttestReference(ctx context.Context, ref *anypb.Any) ([]*common.Selector, error) { + selectors, err := v1.attestByReference(ctx, ref) + if status.Code(err) != codes.Unimplemented { + return selectors, err + } + + pid, extractErr := extractPIDReference(ref) + if extractErr != nil { + // Plugin doesn't implement AttestReference and the reference isn't a + // PID we can fall back on; surface the Unimplemented to the caller. + return nil, err + } + return v1.attestByPID(ctx, pid) +} + +// attestByPID is the raw plugin call without fallback. The wrapping +// public methods orchestrate the cross-RPC fallback so neither raw helper +// can recurse. +func (v1 *V1) attestByPID(ctx context.Context, pid int32) ([]*common.Selector, error) { resp, err := v1.WorkloadAttestorPluginClient.Attest(ctx, &workloadattestorv1.AttestRequest{ - Pid: pidInt32, + Pid: pid, }) if err != nil { return nil, v1.WrapErr(err) } + return v1.selectorsFrom(resp.SelectorValues), nil +} + +// attestByReference is the raw plugin call without fallback. +func (v1 *V1) attestByReference(ctx context.Context, ref *anypb.Any) ([]*common.Selector, error) { + ctx = brokercontext.AppendCallerIDToOutgoingContext(ctx) + resp, err := v1.WorkloadAttestorPluginClient.AttestReference(ctx, &workloadattestorv1.AttestReferenceRequest{ + Reference: ref, + }) + if err != nil { + return nil, v1.WrapErr(err) + } + return v1.selectorsFrom(resp.SelectorValues), nil +} + +func (v1 *V1) selectorsFrom(values []string) []*common.Selector { + if values == nil { + return nil + } + selectors := make([]*common.Selector, 0, len(values)) + for _, value := range values { + selectors = append(selectors, &common.Selector{ + Type: v1.Name(), + Value: value, + }) + } + return selectors +} - var selectors []*common.Selector - if resp.SelectorValues != nil { - selectors = make([]*common.Selector, 0, len(resp.SelectorValues)) - for _, selectorValue := range resp.SelectorValues { - selectors = append(selectors, &common.Selector{ - Type: v1.Name(), - Value: selectorValue, - }) +func extractPIDReference(ref *anypb.Any) (int32, error) { + if ref.GetTypeUrl() == "type.googleapis.com/spiffe.broker.WorkloadPIDReference" { + var pidRef broker.WorkloadPIDReference + if err := anypb.UnmarshalTo(ref, &pidRef, proto.UnmarshalOptions{}); err != nil { + return 0, fmt.Errorf("unmarshaling PID reference: %w", err) } + return pidRef.Pid, nil } - return selectors, nil + return 0, errors.New("PID reference not found") } diff --git a/pkg/agent/plugin/workloadattestor/v1_test.go b/pkg/agent/plugin/workloadattestor/v1_test.go index 854c669b68..b370696240 100644 --- a/pkg/agent/plugin/workloadattestor/v1_test.go +++ b/pkg/agent/plugin/workloadattestor/v1_test.go @@ -4,7 +4,9 @@ import ( "context" "testing" + "github.com/spiffe/go-spiffe/v2/spiffeid" workloadattestorv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/agent/workloadattestor/v1" + "github.com/spiffe/spire/pkg/agent/broker/brokercontext" "github.com/spiffe/spire/pkg/agent/plugin/workloadattestor" "github.com/spiffe/spire/pkg/common/catalog" "github.com/spiffe/spire/proto/spire/common" @@ -13,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/anypb" ) func TestV1(t *testing.T) { @@ -47,6 +50,17 @@ func TestV1(t *testing.T) { }) } +func TestV1AttestReferencePropagatesBrokerCallerID(t *testing.T) { + expectedID := spiffeid.RequireFromString("spiffe://example.org/broker") + fake := &fakeReferencePluginV1{expectedID: expectedID} + plugin := new(workloadattestor.V1) + plugintest.Load(t, catalog.MakeBuiltIn("test", workloadattestorv1.WorkloadAttestorPluginServer(fake)), plugin) + + selectors, err := plugin.AttestReference(brokercontext.WithCallerID(context.Background(), expectedID), &anypb.Any{TypeUrl: "test"}) + require.NoError(t, err) + spiretest.RequireProtoListEqual(t, []*common.Selector{{Type: "test", Value: "broker-id-ok"}}, selectors) +} + func makeFakeV1Plugin(t *testing.T, selectorValues map[int][]string) workloadattestor.WorkloadAttestor { fake := &fakePluginV1{selectorValues: selectorValues} server := workloadattestorv1.WorkloadAttestorPluginServer(fake) @@ -72,3 +86,22 @@ func (plugin fakePluginV1) Attest(_ context.Context, req *workloadattestorv1.Att SelectorValues: selectorValues, }, nil } + +type fakeReferencePluginV1 struct { + workloadattestorv1.UnimplementedWorkloadAttestorServer + expectedID spiffeid.ID +} + +func (plugin *fakeReferencePluginV1) AttestReference(ctx context.Context, _ *workloadattestorv1.AttestReferenceRequest) (*workloadattestorv1.AttestReferenceResponse, error) { + actualID, ok, err := brokercontext.CallerIDFromContext(ctx) + if err != nil { + return nil, err + } + if !ok { + return nil, status.Error(codes.Internal, "missing broker caller") + } + if actualID != plugin.expectedID { + return nil, status.Errorf(codes.Internal, "got broker caller %q", actualID.String()) + } + return &workloadattestorv1.AttestReferenceResponse{SelectorValues: []string{"broker-id-ok"}}, nil +} diff --git a/pkg/agent/plugin/workloadattestor/windows/windows_windows.go b/pkg/agent/plugin/workloadattestor/windows/windows_windows.go index edfcfebf78..b0a7fcf839 100644 --- a/pkg/agent/plugin/workloadattestor/windows/windows_windows.go +++ b/pkg/agent/plugin/workloadattestor/windows/windows_windows.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "sync" + "time" "github.com/hashicorp/go-hclog" "github.com/hashicorp/hcl" @@ -33,8 +34,9 @@ func New() *Plugin { } type Configuration struct { - DiscoverWorkloadPath bool `hcl:"discover_workload_path"` - WorkloadSizeLimit int64 `hcl:"workload_size_limit"` + DiscoverWorkloadPath bool `hcl:"discover_workload_path"` + WorkloadSizeLimit int64 `hcl:"workload_size_limit"` + DisableGroupNameSelectors bool `hcl:"disable_group_name_selectors"` } func buildConfig(coreConfig catalog.CoreConfig, hclText string, status *pluginconf.Status) *Configuration { @@ -77,7 +79,7 @@ func (p *Plugin) Attest(_ context.Context, req *workloadattestorv1.AttestRequest return nil, err } - process, err := p.newProcessInfo(req.Pid, config.DiscoverWorkloadPath) + process, err := p.newProcessInfo(req.Pid, config.DiscoverWorkloadPath, config.DisableGroupNameSelectors) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get process information: %v", err) } @@ -112,7 +114,14 @@ func (p *Plugin) Attest(_ context.Context, req *workloadattestorv1.AttestRequest }, nil } -func (p *Plugin) newProcessInfo(pid int32, queryPath bool) (*processInfo, error) { +// AttestReference returns Unimplemented. This plugin does not handle +// reference-based workload attestation; the host falls back to PID-based +// Attest when the reference is a WorkloadPIDReference. +func (p *Plugin) AttestReference(_ context.Context, _ *workloadattestorv1.AttestReferenceRequest) (*workloadattestorv1.AttestReferenceResponse, error) { + return nil, status.Error(codes.Unimplemented, "AttestReference not implemented") +} + +func (p *Plugin) newProcessInfo(pid int32, queryPath bool, disableGroupNames bool) (*processInfo, error) { p.log = p.log.With(telemetry.PID, pid) h, err := p.q.OpenProcess(pid) @@ -160,6 +169,7 @@ func (p *Plugin) newProcessInfo(pid int32, queryPath bool) (*processInfo, error) } groups := p.q.AllGroups(tokenGroups) + start := time.Now() for _, group := range groups { // Each group has a set of attributes that control how // the system uses the SID in an access check. @@ -167,13 +177,18 @@ func (p *Plugin) newProcessInfo(pid int32, queryPath bool) (*processInfo, error) // https://docs.microsoft.com/en-us/windows/win32/secauthz/sid-attributes-in-an-access-token enabledSelector := getGroupEnabledSelector(group.Attributes) processInfo.groupsSIDs = append(processInfo.groupsSIDs, enabledSelector+":"+group.Sid.String()) - groupAccount, groupDomain, err := p.q.LookupAccount(group.Sid) - if err != nil { - p.log.Warn("failed to lookup account from group SID", "sid", group.Sid, "error", err) - continue + if !disableGroupNames { + groupAccount, groupDomain, err := p.q.LookupAccount(group.Sid) + if err != nil { + p.log.Warn("failed to lookup account from group SID", "sid", group.Sid, "error", err) + continue + } + // If the LookupAccount call succeeded, we know that groupAccount is not empty + processInfo.groups = append(processInfo.groups, enabledSelector+":"+parseAccount(groupAccount, groupDomain)) } - // If the LookupAccount call succeeded, we know that groupAccount is not empty - processInfo.groups = append(processInfo.groups, enabledSelector+":"+parseAccount(groupAccount, groupDomain)) + } + if !disableGroupNames { + p.log.Debug("Group account name lookups completed", "count", len(groups), "duration_ms", time.Since(start).Milliseconds()) } if queryPath { @@ -195,6 +210,10 @@ func (p *Plugin) Configure(_ context.Context, req *configv1.ConfigureRequest) (* defer p.mu.Unlock() p.config = newConfig + if newConfig.DisableGroupNameSelectors { + p.log.Info("Group name selectors disabled; only group_sid selectors will be produced for groups") + } + return &configv1.ConfigureResponse{}, nil } diff --git a/pkg/agent/plugin/workloadattestor/windows/windows_windows_test.go b/pkg/agent/plugin/workloadattestor/windows/windows_windows_test.go index a37b29482b..7aa43b1f18 100644 --- a/pkg/agent/plugin/workloadattestor/windows/windows_windows_test.go +++ b/pkg/agent/plugin/workloadattestor/windows/windows_windows_test.go @@ -115,6 +115,32 @@ func TestAttest(t *testing.T) { }, expectCode: codes.OK, }, + { + name: "successful with groups, group name selectors disabled", + trustDomain: "example.org", + config: "disable_group_name_selectors = true", + pq: &fakeProcessQuery{ + handle: windows.InvalidHandle, + tokenUser: &windows.Tokenuser{User: windows.SIDAndAttributes{Sid: sidUser}}, + tokenGroups: &windows.Tokengroups{Groups: [1]windows.SIDAndAttributes{sidAndAttrGroup1}}, + account: "user1", + domain: "domain1", + sidAndAttributes: []windows.SIDAndAttributes{sidAndAttrGroup1, sidAndAttrGroup3}, + }, + expectSelectors: []string{ + "windows:user_name:domain1\\user1", + "windows:user_sid:" + sidUser.String(), + "windows:group_sid:se_group_enabled:true:" + sidGroup1.String(), + "windows:group_sid:se_group_enabled:true:" + sidGroup3.String(), + }, + expectCode: codes.OK, + expectLogs: []spiretest.LogEntry{ + { + Level: logrus.InfoLevel, + Message: "Group name selectors disabled; only group_sid selectors will be produced for groups", + }, + }, + }, { name: "successful getting path and hashing process binary", trustDomain: "example.org", diff --git a/pkg/agent/plugin/workloadattestor/workloadattestor.go b/pkg/agent/plugin/workloadattestor/workloadattestor.go index b17a586866..ba4458f0d9 100644 --- a/pkg/agent/plugin/workloadattestor/workloadattestor.go +++ b/pkg/agent/plugin/workloadattestor/workloadattestor.go @@ -5,10 +5,12 @@ import ( "github.com/spiffe/spire/pkg/common/catalog" "github.com/spiffe/spire/proto/spire/common" + "google.golang.org/protobuf/types/known/anypb" ) type WorkloadAttestor interface { catalog.PluginInfo Attest(ctx context.Context, pid int) ([]*common.Selector, error) + AttestReference(ctx context.Context, reference *anypb.Any) ([]*common.Selector, error) } diff --git a/pkg/agent/svid/rotator.go b/pkg/agent/svid/rotator.go index c25b893fa7..0d20da432b 100644 --- a/pkg/agent/svid/rotator.go +++ b/pkg/agent/svid/rotator.go @@ -9,7 +9,6 @@ import ( "sync" "github.com/andres-erbsen/clock" - "github.com/imkira/go-observer" "github.com/spiffe/go-spiffe/v2/bundle/spiffebundle" agentv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/server/agent/v1" node_attestor "github.com/spiffe/spire/pkg/agent/attestor/node" @@ -17,6 +16,7 @@ import ( "github.com/spiffe/spire/pkg/agent/plugin/keymanager" "github.com/spiffe/spire/pkg/common/backoff" "github.com/spiffe/spire/pkg/common/nodeutil" + "github.com/spiffe/spire/pkg/common/observer" "github.com/spiffe/spire/pkg/common/rotationutil" "github.com/spiffe/spire/pkg/common/telemetry" telemetry_agent "github.com/spiffe/spire/pkg/common/telemetry/agent" diff --git a/pkg/agent/svid/rotator_config.go b/pkg/agent/svid/rotator_config.go index b38498142e..98b35f256c 100644 --- a/pkg/agent/svid/rotator_config.go +++ b/pkg/agent/svid/rotator_config.go @@ -7,7 +7,6 @@ import ( "time" "github.com/andres-erbsen/clock" - "github.com/imkira/go-observer" "github.com/sirupsen/logrus" "github.com/spiffe/go-spiffe/v2/spiffeid" "github.com/spiffe/spire/pkg/agent/client" @@ -15,6 +14,7 @@ import ( "github.com/spiffe/spire/pkg/agent/plugin/keymanager" "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor" "github.com/spiffe/spire/pkg/common/backoff" + "github.com/spiffe/spire/pkg/common/observer" "github.com/spiffe/spire/pkg/common/rotationutil" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/common/tlspolicy" diff --git a/pkg/agent/svid/rotator_test.go b/pkg/agent/svid/rotator_test.go index ff355e50f2..14bd97c0a3 100644 --- a/pkg/agent/svid/rotator_test.go +++ b/pkg/agent/svid/rotator_test.go @@ -14,7 +14,6 @@ import ( "testing" "time" - "github.com/imkira/go-observer" "github.com/sirupsen/logrus" "github.com/sirupsen/logrus/hooks/test" "github.com/spiffe/go-spiffe/v2/bundle/spiffebundle" @@ -25,6 +24,7 @@ import ( "github.com/spiffe/spire/pkg/agent/manager/cache" "github.com/spiffe/spire/pkg/agent/plugin/keymanager" "github.com/spiffe/spire/pkg/common/idutil" + "github.com/spiffe/spire/pkg/common/observer" "github.com/spiffe/spire/pkg/common/rotationutil" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/test/clock" diff --git a/cmd/spire-server/cli/logger/printers.go b/pkg/common/api/logger/levels.go similarity index 55% rename from cmd/spire-server/cli/logger/printers.go rename to pkg/common/api/logger/levels.go index 8f10fe8706..45182b66ad 100644 --- a/cmd/spire-server/cli/logger/printers.go +++ b/pkg/common/api/logger/levels.go @@ -4,18 +4,38 @@ import ( "errors" "fmt" + "github.com/sirupsen/logrus" apitype "github.com/spiffe/spire-api-sdk/proto/spire/api/types" commoncli "github.com/spiffe/spire/pkg/common/cli" - serverlogger "github.com/spiffe/spire/pkg/server/api/logger/v1" ) +var APILevel = map[logrus.Level]apitype.LogLevel{ + logrus.PanicLevel: apitype.LogLevel_PANIC, + logrus.FatalLevel: apitype.LogLevel_FATAL, + logrus.ErrorLevel: apitype.LogLevel_ERROR, + logrus.WarnLevel: apitype.LogLevel_WARN, + logrus.InfoLevel: apitype.LogLevel_INFO, + logrus.DebugLevel: apitype.LogLevel_DEBUG, + logrus.TraceLevel: apitype.LogLevel_TRACE, +} + +var LogrusLevel = map[apitype.LogLevel]logrus.Level{ + apitype.LogLevel_PANIC: logrus.PanicLevel, + apitype.LogLevel_FATAL: logrus.FatalLevel, + apitype.LogLevel_ERROR: logrus.ErrorLevel, + apitype.LogLevel_WARN: logrus.WarnLevel, + apitype.LogLevel_INFO: logrus.InfoLevel, + apitype.LogLevel_DEBUG: logrus.DebugLevel, + apitype.LogLevel_TRACE: logrus.TraceLevel, +} + func PrettyPrintLogger(env *commoncli.Env, results ...any) error { apiLogger, ok := results[0].(*apitype.Logger) if !ok { return fmt.Errorf("internal error: unexpected type %T returned; please report this as a bug", results[0]) } - logrusCurrent, found := serverlogger.LogrusLevel[apiLogger.CurrentLevel] + logrusCurrent, found := LogrusLevel[apiLogger.CurrentLevel] if !found { return errors.New("internal error: returned current log level is undefined; please report this as a bug") } @@ -24,7 +44,7 @@ func PrettyPrintLogger(env *commoncli.Env, results ...any) error { return fmt.Errorf("internal error: logrus log level %d has no name; please report this as a bug", logrusCurrent) } - logrusLaunch, found := serverlogger.LogrusLevel[apiLogger.LaunchLevel] + logrusLaunch, found := LogrusLevel[apiLogger.LaunchLevel] if !found { return errors.New("internal error: returned launch log level is undefined; please report this as a bug") } diff --git a/pkg/server/api/logger/v1/levels_test.go b/pkg/common/api/logger/levels_test.go similarity index 98% rename from pkg/server/api/logger/v1/levels_test.go rename to pkg/common/api/logger/levels_test.go index 9b40ec879a..148c41f0ad 100644 --- a/pkg/server/api/logger/v1/levels_test.go +++ b/pkg/common/api/logger/levels_test.go @@ -3,11 +3,10 @@ package logger_test import ( "testing" - "github.com/stretchr/testify/require" - "github.com/sirupsen/logrus" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" - "github.com/spiffe/spire/pkg/server/api/logger/v1" + "github.com/spiffe/spire/pkg/common/api/logger" + "github.com/stretchr/testify/require" ) func TestAPILevelValues(t *testing.T) { diff --git a/cmd/spire-server/cli/logger/printers_test.go b/pkg/common/api/logger/printers_test.go similarity index 72% rename from cmd/spire-server/cli/logger/printers_test.go rename to pkg/common/api/logger/printers_test.go index 13ef8ef998..d80bcad0fa 100644 --- a/cmd/spire-server/cli/logger/printers_test.go +++ b/pkg/common/api/logger/printers_test.go @@ -2,15 +2,34 @@ package logger_test import ( "errors" + "io" + "strings" "testing" - "github.com/stretchr/testify/require" - "github.com/spiffe/spire-api-sdk/proto/spire/api/types" - "github.com/spiffe/spire/cmd/spire-server/cli/logger" + "github.com/spiffe/spire/pkg/common/api/logger" commoncli "github.com/spiffe/spire/pkg/common/cli" + "github.com/stretchr/testify/require" ) +type errorWriter struct { + ReturnError error + buf strings.Builder +} + +var _ io.Writer = &errorWriter{} + +func (e *errorWriter) Write(p []byte) (n int, err error) { + if e.ReturnError != nil { + return 0, e.ReturnError + } + return e.buf.Write(p) +} + +func (e *errorWriter) String() string { + return e.buf.String() +} + func TestPrettyPrintLogger(t *testing.T) { for _, tt := range []struct { name string @@ -23,18 +42,15 @@ func TestPrettyPrintLogger(t *testing.T) { expectedError error }{ { - name: "test", + name: "pretty print debug/info", logger: &types.Logger{ CurrentLevel: types.LogLevel_DEBUG, LaunchLevel: types.LogLevel_INFO, }, - expectedStdout: `Logger Level : debug -Launch Level : info - -`, + expectedStdout: "Logger Level : debug\nLaunch Level : info\n\n", }, { - name: "test env returning an error", + name: "writer error", outWriter: errorWriter{ ReturnError: errors.New("cannot write"), }, @@ -45,7 +61,7 @@ Launch Level : info expectedError: errors.New("cannot write"), }, { - name: "test nil logger", + name: "wrong proto type", outWriter: errorWriter{ ReturnError: errors.New("cannot write"), }, diff --git a/pkg/common/api/middleware/names.go b/pkg/common/api/middleware/names.go index 9895472cb8..5f481bdb28 100644 --- a/pkg/common/api/middleware/names.go +++ b/pkg/common/api/middleware/names.go @@ -19,7 +19,8 @@ const ( EnvoySDSv3ServiceShortName = "SDS.v3" HealthServiceName = "grpc.health.v1.Health" HealthServiceShortName = "Health" - LoggerServiceName = "logger.v1.Logger" + ServerLoggerServiceName = "logger.v1.Logger" + AgentLoggerServiceName = "spire.api.agent.logger.v1.Logger" LoggerServiceShortName = "Logger" DebugServiceName = "spire.agent.debug.v1.Debug" DebugServiceShortName = "Debug" @@ -37,7 +38,8 @@ var ( WorkloadAPIServiceName, WorkloadAPIServiceShortName, EnvoySDSv3ServiceName, EnvoySDSv3ServiceShortName, HealthServiceName, HealthServiceShortName, - LoggerServiceName, LoggerServiceShortName, + ServerLoggerServiceName, LoggerServiceShortName, + AgentLoggerServiceName, LoggerServiceShortName, DebugServiceName, DebugServiceShortName, DelegatedIdentityServiceName, DelegatedIdentityServiceShortName, ) diff --git a/pkg/common/api/middleware/names_test.go b/pkg/common/api/middleware/names_test.go index 071a532d3f..126da8a353 100644 --- a/pkg/common/api/middleware/names_test.go +++ b/pkg/common/api/middleware/names_test.go @@ -15,3 +15,118 @@ func TestMetricKey(t *testing.T) { assert.Equal(t, "one_two_three", metricKey("OneTWOThree")) assert.Equal(t, "one_two_three", metricKey("ONETwoTHREE")) } + +func TestMakeMetricName(t *testing.T) { + for _, tt := range []struct { + fullMethod string + service string + metricKey []string + }{ + // server logger + {fullMethod: "/spire.api.server.logger.v1.Logger/GetLogger", service: "Logger", metricKey: []string{"logger", "get_logger"}}, + {fullMethod: "/spire.api.server.logger.v1.Logger/SetLogLevel", service: "Logger", metricKey: []string{"logger", "set_log_level"}}, + {fullMethod: "/spire.api.server.logger.v1.Logger/ResetLogLevel", service: "Logger", metricKey: []string{"logger", "reset_log_level"}}, + // agent logger + {fullMethod: "/spire.api.agent.logger.v1.Logger/GetLogger", service: "Logger", metricKey: []string{"logger", "get_logger"}}, + {fullMethod: "/spire.api.agent.logger.v1.Logger/SetLogLevel", service: "Logger", metricKey: []string{"logger", "set_log_level"}}, + {fullMethod: "/spire.api.agent.logger.v1.Logger/ResetLogLevel", service: "Logger", metricKey: []string{"logger", "reset_log_level"}}, + // server agent + {fullMethod: "/spire.api.server.agent.v1.Agent/AttestAgent", service: "agent.v1.Agent", metricKey: []string{"agent", "v1", "agent", "attest_agent"}}, + {fullMethod: "/spire.api.server.agent.v1.Agent/BanAgent", service: "agent.v1.Agent", metricKey: []string{"agent", "v1", "agent", "ban_agent"}}, + {fullMethod: "/spire.api.server.agent.v1.Agent/CountAgents", service: "agent.v1.Agent", metricKey: []string{"agent", "v1", "agent", "count_agents"}}, + {fullMethod: "/spire.api.server.agent.v1.Agent/CreateJoinToken", service: "agent.v1.Agent", metricKey: []string{"agent", "v1", "agent", "create_join_token"}}, + {fullMethod: "/spire.api.server.agent.v1.Agent/DeleteAgent", service: "agent.v1.Agent", metricKey: []string{"agent", "v1", "agent", "delete_agent"}}, + {fullMethod: "/spire.api.server.agent.v1.Agent/GetAgent", service: "agent.v1.Agent", metricKey: []string{"agent", "v1", "agent", "get_agent"}}, + {fullMethod: "/spire.api.server.agent.v1.Agent/ListAgents", service: "agent.v1.Agent", metricKey: []string{"agent", "v1", "agent", "list_agents"}}, + {fullMethod: "/spire.api.server.agent.v1.Agent/PostStatus", service: "agent.v1.Agent", metricKey: []string{"agent", "v1", "agent", "post_status"}}, + {fullMethod: "/spire.api.server.agent.v1.Agent/RenewAgent", service: "agent.v1.Agent", metricKey: []string{"agent", "v1", "agent", "renew_agent"}}, + // server bundle + {fullMethod: "/spire.api.server.bundle.v1.Bundle/AppendBundle", service: "bundle.v1.Bundle", metricKey: []string{"bundle", "v1", "bundle", "append_bundle"}}, + {fullMethod: "/spire.api.server.bundle.v1.Bundle/BatchCreateFederatedBundle", service: "bundle.v1.Bundle", metricKey: []string{"bundle", "v1", "bundle", "batch_create_federated_bundle"}}, + {fullMethod: "/spire.api.server.bundle.v1.Bundle/BatchDeleteFederatedBundle", service: "bundle.v1.Bundle", metricKey: []string{"bundle", "v1", "bundle", "batch_delete_federated_bundle"}}, + {fullMethod: "/spire.api.server.bundle.v1.Bundle/BatchSetFederatedBundle", service: "bundle.v1.Bundle", metricKey: []string{"bundle", "v1", "bundle", "batch_set_federated_bundle"}}, + {fullMethod: "/spire.api.server.bundle.v1.Bundle/BatchUpdateFederatedBundle", service: "bundle.v1.Bundle", metricKey: []string{"bundle", "v1", "bundle", "batch_update_federated_bundle"}}, + {fullMethod: "/spire.api.server.bundle.v1.Bundle/CountBundles", service: "bundle.v1.Bundle", metricKey: []string{"bundle", "v1", "bundle", "count_bundles"}}, + {fullMethod: "/spire.api.server.bundle.v1.Bundle/GetBundle", service: "bundle.v1.Bundle", metricKey: []string{"bundle", "v1", "bundle", "get_bundle"}}, + {fullMethod: "/spire.api.server.bundle.v1.Bundle/GetFederatedBundle", service: "bundle.v1.Bundle", metricKey: []string{"bundle", "v1", "bundle", "get_federated_bundle"}}, + {fullMethod: "/spire.api.server.bundle.v1.Bundle/ListFederatedBundles", service: "bundle.v1.Bundle", metricKey: []string{"bundle", "v1", "bundle", "list_federated_bundles"}}, + {fullMethod: "/spire.api.server.bundle.v1.Bundle/PublishJWTAuthority", service: "bundle.v1.Bundle", metricKey: []string{"bundle", "v1", "bundle", "publish_jwt_authority"}}, + {fullMethod: "/spire.api.server.bundle.v1.Bundle/PublishWITAuthority", service: "bundle.v1.Bundle", metricKey: []string{"bundle", "v1", "bundle", "publish_wit_authority"}}, + // server debug + {fullMethod: "/spire.api.server.debug.v1.Debug/GetInfo", service: "debug.v1.Debug", metricKey: []string{"debug", "v1", "debug", "get_info"}}, + // agent debug + {fullMethod: "/spire.agent.debug.v1.Debug/GetInfo", service: "Debug", metricKey: []string{"debug", "get_info"}}, + // server entry + {fullMethod: "/spire.api.server.entry.v1.Entry/BatchCreateEntry", service: "entry.v1.Entry", metricKey: []string{"entry", "v1", "entry", "batch_create_entry"}}, + {fullMethod: "/spire.api.server.entry.v1.Entry/BatchDeleteEntry", service: "entry.v1.Entry", metricKey: []string{"entry", "v1", "entry", "batch_delete_entry"}}, + {fullMethod: "/spire.api.server.entry.v1.Entry/BatchUpdateEntry", service: "entry.v1.Entry", metricKey: []string{"entry", "v1", "entry", "batch_update_entry"}}, + {fullMethod: "/spire.api.server.entry.v1.Entry/CountEntries", service: "entry.v1.Entry", metricKey: []string{"entry", "v1", "entry", "count_entries"}}, + {fullMethod: "/spire.api.server.entry.v1.Entry/GetAuthorizedEntries", service: "entry.v1.Entry", metricKey: []string{"entry", "v1", "entry", "get_authorized_entries"}}, + {fullMethod: "/spire.api.server.entry.v1.Entry/GetEntry", service: "entry.v1.Entry", metricKey: []string{"entry", "v1", "entry", "get_entry"}}, + {fullMethod: "/spire.api.server.entry.v1.Entry/ListEntries", service: "entry.v1.Entry", metricKey: []string{"entry", "v1", "entry", "list_entries"}}, + {fullMethod: "/spire.api.server.entry.v1.Entry/SyncAuthorizedEntries", service: "entry.v1.Entry", metricKey: []string{"entry", "v1", "entry", "sync_authorized_entries"}}, + // server localauthority + {fullMethod: "/spire.api.server.localauthority.v1.LocalAuthority/ActivateJWTAuthority", service: "localauthority.v1.LocalAuthority", metricKey: []string{"localauthority", "v1", "local_authority", "activate_jwt_authority"}}, + {fullMethod: "/spire.api.server.localauthority.v1.LocalAuthority/ActivateWITAuthority", service: "localauthority.v1.LocalAuthority", metricKey: []string{"localauthority", "v1", "local_authority", "activate_wit_authority"}}, + {fullMethod: "/spire.api.server.localauthority.v1.LocalAuthority/ActivateX509Authority", service: "localauthority.v1.LocalAuthority", metricKey: []string{"localauthority", "v1", "local_authority", "activate_x509_authority"}}, + {fullMethod: "/spire.api.server.localauthority.v1.LocalAuthority/GetJWTAuthorityState", service: "localauthority.v1.LocalAuthority", metricKey: []string{"localauthority", "v1", "local_authority", "get_jwt_authority_state"}}, + {fullMethod: "/spire.api.server.localauthority.v1.LocalAuthority/GetWITAuthorityState", service: "localauthority.v1.LocalAuthority", metricKey: []string{"localauthority", "v1", "local_authority", "get_wit_authority_state"}}, + {fullMethod: "/spire.api.server.localauthority.v1.LocalAuthority/GetX509AuthorityState", service: "localauthority.v1.LocalAuthority", metricKey: []string{"localauthority", "v1", "local_authority", "get_x509_authority_state"}}, + {fullMethod: "/spire.api.server.localauthority.v1.LocalAuthority/PrepareJWTAuthority", service: "localauthority.v1.LocalAuthority", metricKey: []string{"localauthority", "v1", "local_authority", "prepare_jwt_authority"}}, + {fullMethod: "/spire.api.server.localauthority.v1.LocalAuthority/PrepareWITAuthority", service: "localauthority.v1.LocalAuthority", metricKey: []string{"localauthority", "v1", "local_authority", "prepare_wit_authority"}}, + {fullMethod: "/spire.api.server.localauthority.v1.LocalAuthority/PrepareX509Authority", service: "localauthority.v1.LocalAuthority", metricKey: []string{"localauthority", "v1", "local_authority", "prepare_x509_authority"}}, + {fullMethod: "/spire.api.server.localauthority.v1.LocalAuthority/RevokeJWTAuthority", service: "localauthority.v1.LocalAuthority", metricKey: []string{"localauthority", "v1", "local_authority", "revoke_jwt_authority"}}, + {fullMethod: "/spire.api.server.localauthority.v1.LocalAuthority/RevokeWITAuthority", service: "localauthority.v1.LocalAuthority", metricKey: []string{"localauthority", "v1", "local_authority", "revoke_wit_authority"}}, + {fullMethod: "/spire.api.server.localauthority.v1.LocalAuthority/RevokeX509Authority", service: "localauthority.v1.LocalAuthority", metricKey: []string{"localauthority", "v1", "local_authority", "revoke_x509_authority"}}, + {fullMethod: "/spire.api.server.localauthority.v1.LocalAuthority/RevokeX509UpstreamAuthority", service: "localauthority.v1.LocalAuthority", metricKey: []string{"localauthority", "v1", "local_authority", "revoke_x509_upstream_authority"}}, + {fullMethod: "/spire.api.server.localauthority.v1.LocalAuthority/TaintJWTAuthority", service: "localauthority.v1.LocalAuthority", metricKey: []string{"localauthority", "v1", "local_authority", "taint_jwt_authority"}}, + {fullMethod: "/spire.api.server.localauthority.v1.LocalAuthority/TaintWITAuthority", service: "localauthority.v1.LocalAuthority", metricKey: []string{"localauthority", "v1", "local_authority", "taint_wit_authority"}}, + {fullMethod: "/spire.api.server.localauthority.v1.LocalAuthority/TaintX509Authority", service: "localauthority.v1.LocalAuthority", metricKey: []string{"localauthority", "v1", "local_authority", "taint_x509_authority"}}, + {fullMethod: "/spire.api.server.localauthority.v1.LocalAuthority/TaintX509UpstreamAuthority", service: "localauthority.v1.LocalAuthority", metricKey: []string{"localauthority", "v1", "local_authority", "taint_x509_upstream_authority"}}, + // server svid + {fullMethod: "/spire.api.server.svid.v1.SVID/BatchNewWITSVID", service: "svid.v1.SVID", metricKey: []string{"svid", "v1", "svid", "batch_new_witsvid"}}, + {fullMethod: "/spire.api.server.svid.v1.SVID/BatchNewX509SVID", service: "svid.v1.SVID", metricKey: []string{"svid", "v1", "svid", "batch_new_x509svid"}}, + {fullMethod: "/spire.api.server.svid.v1.SVID/MintJWTSVID", service: "svid.v1.SVID", metricKey: []string{"svid", "v1", "svid", "mint_jwtsvid"}}, + {fullMethod: "/spire.api.server.svid.v1.SVID/MintWITSVID", service: "svid.v1.SVID", metricKey: []string{"svid", "v1", "svid", "mint_witsvid"}}, + {fullMethod: "/spire.api.server.svid.v1.SVID/MintX509SVID", service: "svid.v1.SVID", metricKey: []string{"svid", "v1", "svid", "mint_x509svid"}}, + {fullMethod: "/spire.api.server.svid.v1.SVID/NewDownstreamX509CA", service: "svid.v1.SVID", metricKey: []string{"svid", "v1", "svid", "new_downstream_x509ca"}}, + {fullMethod: "/spire.api.server.svid.v1.SVID/NewJWTSVID", service: "svid.v1.SVID", metricKey: []string{"svid", "v1", "svid", "new_jwtsvid"}}, + // server trustdomain + {fullMethod: "/spire.api.server.trustdomain.v1.TrustDomain/BatchCreateFederationRelationship", service: "trustdomain.v1.TrustDomain", metricKey: []string{"trustdomain", "v1", "trust_domain", "batch_create_federation_relationship"}}, + {fullMethod: "/spire.api.server.trustdomain.v1.TrustDomain/BatchDeleteFederationRelationship", service: "trustdomain.v1.TrustDomain", metricKey: []string{"trustdomain", "v1", "trust_domain", "batch_delete_federation_relationship"}}, + {fullMethod: "/spire.api.server.trustdomain.v1.TrustDomain/BatchUpdateFederationRelationship", service: "trustdomain.v1.TrustDomain", metricKey: []string{"trustdomain", "v1", "trust_domain", "batch_update_federation_relationship"}}, + {fullMethod: "/spire.api.server.trustdomain.v1.TrustDomain/GetFederationRelationship", service: "trustdomain.v1.TrustDomain", metricKey: []string{"trustdomain", "v1", "trust_domain", "get_federation_relationship"}}, + {fullMethod: "/spire.api.server.trustdomain.v1.TrustDomain/ListFederationRelationships", service: "trustdomain.v1.TrustDomain", metricKey: []string{"trustdomain", "v1", "trust_domain", "list_federation_relationships"}}, + {fullMethod: "/spire.api.server.trustdomain.v1.TrustDomain/RefreshBundle", service: "trustdomain.v1.TrustDomain", metricKey: []string{"trustdomain", "v1", "trust_domain", "refresh_bundle"}}, + // agent delegatedidentity + {fullMethod: "/spire.api.agent.delegatedidentity.v1.DelegatedIdentity/FetchJWTSVIDs", service: "DelegatedIdentity", metricKey: []string{"delegated_identity", "fetch_jwtsvi_ds"}}, + {fullMethod: "/spire.api.agent.delegatedidentity.v1.DelegatedIdentity/SubscribeToJWTBundles", service: "DelegatedIdentity", metricKey: []string{"delegated_identity", "subscribe_to_jwt_bundles"}}, + {fullMethod: "/spire.api.agent.delegatedidentity.v1.DelegatedIdentity/SubscribeToX509Bundles", service: "DelegatedIdentity", metricKey: []string{"delegated_identity", "subscribe_to_x509_bundles"}}, + {fullMethod: "/spire.api.agent.delegatedidentity.v1.DelegatedIdentity/SubscribeToX509SVIDs", service: "DelegatedIdentity", metricKey: []string{"delegated_identity", "subscribe_to_x509_svids"}}, + // workload api + {fullMethod: "/SpiffeWorkloadAPI/FetchJWTBundles", service: "WorkloadAPI", metricKey: []string{"workload_api", "fetch_jwt_bundles"}}, + {fullMethod: "/SpiffeWorkloadAPI/FetchJWTSVID", service: "WorkloadAPI", metricKey: []string{"workload_api", "fetch_jwtsvid"}}, + {fullMethod: "/SpiffeWorkloadAPI/FetchWITBundles", service: "WorkloadAPI", metricKey: []string{"workload_api", "fetch_wit_bundles"}}, + {fullMethod: "/SpiffeWorkloadAPI/FetchWITSVID", service: "WorkloadAPI", metricKey: []string{"workload_api", "fetch_witsvid"}}, + {fullMethod: "/SpiffeWorkloadAPI/FetchX509Bundles", service: "WorkloadAPI", metricKey: []string{"workload_api", "fetch_x509_bundles"}}, + {fullMethod: "/SpiffeWorkloadAPI/FetchX509SVID", service: "WorkloadAPI", metricKey: []string{"workload_api", "fetch_x509svid"}}, + {fullMethod: "/SpiffeWorkloadAPI/ValidateJWTSVID", service: "WorkloadAPI", metricKey: []string{"workload_api", "validate_jwtsvid"}}, + // envoy sds v3 + {fullMethod: "/envoy.service.secret.v3.SecretDiscoveryService/DeltaSecrets", service: "SDS.v3", metricKey: []string{"sds", "v3", "delta_secrets"}}, + {fullMethod: "/envoy.service.secret.v3.SecretDiscoveryService/FetchSecrets", service: "SDS.v3", metricKey: []string{"sds", "v3", "fetch_secrets"}}, + {fullMethod: "/envoy.service.secret.v3.SecretDiscoveryService/StreamSecrets", service: "SDS.v3", metricKey: []string{"sds", "v3", "stream_secrets"}}, + // health + {fullMethod: "/grpc.health.v1.Health/Check", service: "Health", metricKey: []string{"health", "check"}}, + {fullMethod: "/grpc.health.v1.Health/List", service: "Health", metricKey: []string{"health", "list"}}, + {fullMethod: "/grpc.health.v1.Health/Watch", service: "Health", metricKey: []string{"health", "watch"}}, + // reflection + {fullMethod: "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo", service: "grpc.reflection.v1.ServerReflection", metricKey: []string{"grpc", "reflection", "v1", "server_reflection", "server_reflection_info"}}, + {fullMethod: "/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo", service: "grpc.reflection.v1alpha.ServerReflection", metricKey: []string{"grpc", "reflection", "v1alpha", "server_reflection", "server_reflection_info"}}, + } { + t.Run(tt.fullMethod, func(t *testing.T) { + names := makeNames(tt.fullMethod) + assert.Equal(t, tt.service, names.Service) + assert.Equal(t, tt.metricKey, names.MetricKey) + }) + } +} diff --git a/pkg/server/api/status.go b/pkg/common/api/status.go similarity index 100% rename from pkg/server/api/status.go rename to pkg/common/api/status.go diff --git a/pkg/server/api/status_test.go b/pkg/common/api/status_test.go similarity index 98% rename from pkg/server/api/status_test.go rename to pkg/common/api/status_test.go index b2782d0b71..67a3c611c5 100644 --- a/pkg/server/api/status_test.go +++ b/pkg/common/api/status_test.go @@ -7,7 +7,7 @@ import ( "github.com/sirupsen/logrus" "github.com/sirupsen/logrus/hooks/test" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" - "github.com/spiffe/spire/pkg/server/api" + "github.com/spiffe/spire/pkg/common/api" "github.com/spiffe/spire/test/spiretest" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" diff --git a/pkg/common/containerinfo/extract.go b/pkg/common/containerinfo/extract.go index ae4cb2d1d4..1c8715da31 100644 --- a/pkg/common/containerinfo/extract.go +++ b/pkg/common/containerinfo/extract.go @@ -5,7 +5,6 @@ package containerinfo import ( "errors" "fmt" - "io" "io/fs" "os" "path" @@ -18,7 +17,6 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "k8s.io/apimachinery/pkg/types" - "k8s.io/mount-utils" ) var ( @@ -50,22 +48,28 @@ func (e *Extractor) GetPodUIDAndContainerID(pid int32, log hclog.Logger) (types. } func (e *Extractor) extractInfo(pid int32, log hclog.Logger, extractPodUID bool) (types.UID, string, error) { - // Try to get the information from /proc/pid/mountinfo first. Otherwise, - // fall back to /proc/pid/cgroup. If it isn't in mountinfo, then the - // workload being attested likely originates in the same Pod as the agent. + // Try to get the information from /proc/pid/cgroup first. The kernel + // controls this file and a workload cannot forge it, so it is the + // authoritative source of identity. + // + // Only fall back to /proc/pid/mountinfo when cgroup yields nothing (e.g. + // same-pod attestation where the cgroup path lacks container identifiers). + // mountinfo is controlled by the workload's mount namespace and can be + // crafted to impersonate another workload by bind-mounting a foreign + // cgroup, so it must not take precedence over the cgroup file. // // It may not be possible to attest a process running in the same container // as the agent because, depending on how cgroups are being used, // /proc//mountinfo or /proc//cgroup may not contain any // information on the container ID or pod. - podUID, containerID, err := e.extractPodUIDAndContainerIDFromMountInfo(pid, log, extractPodUID) + podUID, containerID, err := e.extractPodUIDAndContainerIDFromCGroups(pid, log, extractPodUID) if err != nil { return "", "", err } if containerID == "" { - podUID, containerID, err = e.extractPodUIDAndContainerIDFromCGroups(pid, log, extractPodUID) + podUID, containerID, err = e.extractPodUIDAndContainerIDFromMountInfo(pid, log, extractPodUID) if err != nil { return "", "", err } @@ -77,7 +81,7 @@ func (e *Extractor) extractInfo(pid int32, log hclog.Logger, extractPodUID bool) func (e *Extractor) extractPodUIDAndContainerIDFromMountInfo(pid int32, log hclog.Logger, extractPodUID bool) (types.UID, string, error) { mountInfoPath := filepath.Join(e.RootDir, "/proc", fmt.Sprint(pid), "mountinfo") - mountInfos, err := mount.ParseMountInfo(mountInfoPath) + mountInfos, err := parseMountInfo(mountInfoPath) if err != nil { if errors.Is(err, fs.ErrNotExist) { return "", "", nil @@ -131,7 +135,7 @@ func (e *Extractor) extractPodUIDAndContainerIDFromMountInfo(pid int32, log hclo } func (e *Extractor) extractPodUIDAndContainerIDFromCGroups(pid int32, log hclog.Logger, extractPodUID bool) (types.UID, string, error) { - cgroups, err := cgroups.GetCgroups(pid, dirFS(e.RootDir)) + cgroups, err := cgroups.GetCgroups(pid, os.DirFS(e.RootDir)) if err != nil { if errors.Is(err, fs.ErrNotExist) { return "", "", nil @@ -159,12 +163,6 @@ func (e *Extractor) extractPodUIDAndContainerIDFromCGroups(pid int32, log hclog. return ex.PodUID(), ex.ContainerID(), nil } -type dirFS string - -func (d dirFS) Open(p string) (io.ReadCloser, error) { - return os.Open(filepath.Join(string(d), p)) -} - type extractor struct { podUID types.UID containerID string diff --git a/pkg/common/containerinfo/extract_test.go b/pkg/common/containerinfo/extract_test.go index a3a6361d21..8717043fc4 100644 --- a/pkg/common/containerinfo/extract_test.go +++ b/pkg/common/containerinfo/extract_test.go @@ -71,12 +71,26 @@ func TestExtractPodUIDAndContainerID(t *testing.T) { assertErrorContains(t, "testdata/k8s/container-id-conflict", "multiple container IDs found") }) - t.Run("failed to read mountinfo", func(t *testing.T) { + t.Run("neither cgroup nor mountinfo exist", func(t *testing.T) { assertNotFound(t, "testdata/does-not-exist") }) - t.Run("falls back to cgroup file", func(t *testing.T) { - assertFound(t, "testdata/other/fallback", "", testContainerID) + t.Run("extracts from cgroup file", func(t *testing.T) { + assertFound(t, "testdata/other/cgroup-primary", "", testContainerID) + }) + + t.Run("falls back to mountinfo", func(t *testing.T) { + // The cgroup file exists but contains no identifiers, so the + // information is extracted from mountinfo instead. + assertFound(t, "testdata/other/mountinfo-fallback", testPodUID, testContainerID) + }) + + t.Run("cgroup takes priority over spoofed mountinfo", func(t *testing.T) { + // A workload may bind-mount another workload's cgroup into its own + // mount namespace so that mountinfo reports a different pod/container + // than the kernel-assigned cgroup. The cgroup file cannot be forged, + // so it must take priority. + assertFound(t, "testdata/other/mountinfo-impersonation", "", testContainerID) }) } @@ -125,12 +139,26 @@ func TestExtractContainerID(t *testing.T) { assertErrorContains(t, "testdata/docker/container-id-conflict", "multiple container IDs found") }) - t.Run("failed to read mountinfo", func(t *testing.T) { + t.Run("neither cgroup nor mountinfo exist", func(t *testing.T) { assertNotFound(t, "testdata/does-not-exist") }) - t.Run("falls back to cgroup file", func(t *testing.T) { - assertFound(t, "testdata/other/fallback", testContainerID) + t.Run("extracts from cgroup file", func(t *testing.T) { + assertFound(t, "testdata/other/cgroup-primary", testContainerID) + }) + + t.Run("falls back to mountinfo", func(t *testing.T) { + // The cgroup file exists but contains no identifiers, so the + // information is extracted from mountinfo instead. + assertFound(t, "testdata/other/mountinfo-fallback", testContainerID) + }) + + t.Run("cgroup takes priority over spoofed mountinfo", func(t *testing.T) { + // A workload may bind-mount another workload's cgroup into its own + // mount namespace so that mountinfo reports a different container + // than the kernel-assigned cgroup. The cgroup file cannot be forged, + // so it must take priority. + assertFound(t, "testdata/other/mountinfo-impersonation", testContainerID) }) t.Run("has multiple cgroup mounts on slash v1", func(t *testing.T) { @@ -140,4 +168,12 @@ func TestExtractContainerID(t *testing.T) { t.Run("has multiple cgroup mounts on slash v2", func(t *testing.T) { assertFound(t, "testdata/docker/cgroup-mount-at-slash/v2", testContainerID) }) + + t.Run("tolerates tmpfs mount with empty source", func(t *testing.T) { + // Regression test for #7036: a tmpfs mount has no source, which renders + // as an empty field in /proc//mountinfo. The container ID must + // still be extracted from the cgroup mount on the same file rather than + // the whole file being rejected. + assertFound(t, "testdata/docker/tmpfs-empty-source", testContainerID) + }) } diff --git a/pkg/common/containerinfo/mountinfo.go b/pkg/common/containerinfo/mountinfo.go new file mode 100644 index 0000000000..dd9ca41e53 --- /dev/null +++ b/pkg/common/containerinfo/mountinfo.go @@ -0,0 +1,107 @@ +//go:build !windows + +package containerinfo + +import ( + "bufio" + "fmt" + "os" + "slices" + "strings" +) + +// mountInfo holds the subset of /proc//mountinfo fields that the +// container info extractor consumes. The full mountinfo line format is +// documented in proc(5). +type mountInfo struct { + // Root is the pathname of the directory in the filesystem which forms the + // root of this mount (field 4). + Root string + // FsType is the filesystem type (the first field after the "-" separator). + FsType string +} + +// parseMountInfo parses /proc//mountinfo. +// +// It exists because k8s.io/mount-utils ParseMountInfo splits each line with +// strings.Fields, which collapses runs of whitespace and therefore drops an +// empty mount source field. Per proc(5) the mount source (the field after the +// filesystem type) may be empty (for example a tmpfs mount has no source), and +// the kernel escapes any real whitespace in a path as octal (\040). A line +// like: +// +// 119 206 0:68 / /local rw,relatime - tmpfs rw,size=8192k +// +// is therefore valid: the double space between "tmpfs" and "rw,size=8192k" +// unambiguously means an empty source. The upstream parser counts that as 9 +// fields (it expects at least 10) and rejects the entire file, which made the +// docker and k8s workload attestors fail attestation outright. This parser +// uses strings.Split (single-space delimiter) so the empty source field is +// preserved as an empty string rather than collapsed. +func parseMountInfo(filename string) ([]mountInfo, error) { + f, err := os.Open(filename) + if err != nil { + return nil, err + } + defer f.Close() + + var infos []mountInfo + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + info, err := parseMountInfoLine(line) + if err != nil { + return nil, err + } + infos = append(infos, info) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return infos, nil +} + +// parseMountInfoLine parses a single mountinfo line. The format (see proc(5)) +// is, in order: +// +// (1) mount ID (2) parent ID (3) major:minor (4) root (5) mount point +// (6) mount options (7..) zero or more optional fields (8) a "-" separator +// (9) filesystem type (10) mount source (11) super options +// +// Only the fields the extractor needs (root and filesystem type) are returned. +// The line is split on single spaces so that an empty mount source (field 10) +// is preserved as an empty string rather than collapsed by strings.Fields. +func parseMountInfoLine(line string) (mountInfo, error) { + // Split on single spaces so empty fields (e.g. an empty mount source + // between "tmpfs" and the super options) survive as empty strings. + fields := strings.Split(line, " ") + + // Locate the "-" separator. Everything before it is the fixed + + // optional-tag fields; everything after is fstype, source, super options. + sepIdx := slices.Index(fields, "-") + if sepIdx < 0 { + return mountInfo{}, fmt.Errorf("missing separator in mountinfo line: %s", line) + } + + // Before the separator: mount ID (0), parent ID (1), major:minor (2), + // root (3), mount point (4), mount options (5), then zero or more + // optional fields. We need at least 6. + if sepIdx < 6 { + return mountInfo{}, fmt.Errorf("expected at least 6 fields before separator in mountinfo line: %s", line) + } + + // After the separator: filesystem type (sepIdx+1), mount source + // (sepIdx+2, may be empty), super options (sepIdx+3). We only need + // the filesystem type. + if sepIdx+1 >= len(fields) || fields[sepIdx+1] == "" { + return mountInfo{}, fmt.Errorf("missing filesystem type in mountinfo line: %s", line) + } + + return mountInfo{ + Root: fields[3], + FsType: fields[sepIdx+1], + }, nil +} diff --git a/pkg/common/containerinfo/mountinfo_test.go b/pkg/common/containerinfo/mountinfo_test.go new file mode 100644 index 0000000000..16b32f40cf --- /dev/null +++ b/pkg/common/containerinfo/mountinfo_test.go @@ -0,0 +1,90 @@ +//go:build !windows + +package containerinfo + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseMountInfoLine(t *testing.T) { + for _, tt := range []struct { + name string + line string + wantRoot string + wantType string + wantErr string + }{ + { + name: "normal cgroup2 line", + line: "1543 1542 0:32 /some/root /sys/fs/cgroup ro,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw,nsdelegate", + wantRoot: "/some/root", + wantType: "cgroup2", + }, + { + name: "optional fields before separator", + line: "573 572 0:33 /docker/abc /sys/fs/cgroup/systemd ro,nosuid,nodev,noexec,relatime master:11 - cgroup cgroup rw,name=systemd", + wantRoot: "/docker/abc", + wantType: "cgroup", + }, + { + // Regression for #7036: a tmpfs mount has no source, so the field + // after the filesystem type is empty. strings.Fields would collapse + // it and the upstream parser rejected the whole file. + name: "tmpfs with empty source", + line: "119 206 0:68 / /local rw,relatime - tmpfs rw,size=8192k", + wantRoot: "/", + wantType: "tmpfs", + }, + { + name: "missing separator", + line: "1543 1542 0:32 /some/root /sys/fs/cgroup ro,nosuid,nodev,noexec,relatime cgroup2 cgroup rw", + wantErr: "missing separator", + }, + { + name: "too few fields before separator", + line: "1543 1542 0:32 - cgroup2 cgroup rw", + wantErr: "expected at least 6 fields before separator", + }, + { + name: "missing filesystem type after separator", + line: "1543 1542 0:32 /some/root /sys/fs/cgroup rw - ", + wantErr: "missing filesystem type", + }, + } { + t.Run(tt.name, func(t *testing.T) { + info, err := parseMountInfoLine(tt.line) + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantRoot, info.Root) + assert.Equal(t, tt.wantType, info.FsType) + }) + } +} + +func TestParseMountInfo(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mountinfo") + content := "" + + "2356 2355 0:30 /../containerid /sys/fs/cgroup ro,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw\n" + + "119 206 0:68 / /local rw,relatime - tmpfs rw,size=8192k\n" + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + infos, err := parseMountInfo(path) + require.NoError(t, err) + require.Len(t, infos, 2) + + assert.Equal(t, "/../containerid", infos[0].Root) + assert.Equal(t, "cgroup2", infos[0].FsType) + + // The tmpfs line with an empty source must still parse. + assert.Equal(t, "/", infos[1].Root) + assert.Equal(t, "tmpfs", infos[1].FsType) +} diff --git a/pkg/common/containerinfo/testdata/docker/tmpfs-empty-source/proc/123/mountinfo b/pkg/common/containerinfo/testdata/docker/tmpfs-empty-source/proc/123/mountinfo new file mode 100644 index 0000000000..dd80daaf15 --- /dev/null +++ b/pkg/common/containerinfo/testdata/docker/tmpfs-empty-source/proc/123/mountinfo @@ -0,0 +1,2 @@ +2356 2355 0:30 /../0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef /sys/fs/cgroup ro,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw +119 206 0:68 / /local rw,relatime - tmpfs rw,size=8192k diff --git a/pkg/common/containerinfo/testdata/other/fallback/proc/123/cgroup b/pkg/common/containerinfo/testdata/other/cgroup-primary/proc/123/cgroup similarity index 100% rename from pkg/common/containerinfo/testdata/other/fallback/proc/123/cgroup rename to pkg/common/containerinfo/testdata/other/cgroup-primary/proc/123/cgroup diff --git a/pkg/common/containerinfo/testdata/other/fallback/proc/123/mountinfo b/pkg/common/containerinfo/testdata/other/cgroup-primary/proc/123/mountinfo similarity index 100% rename from pkg/common/containerinfo/testdata/other/fallback/proc/123/mountinfo rename to pkg/common/containerinfo/testdata/other/cgroup-primary/proc/123/mountinfo diff --git a/pkg/common/containerinfo/testdata/other/mountinfo-fallback/proc/123/cgroup b/pkg/common/containerinfo/testdata/other/mountinfo-fallback/proc/123/cgroup new file mode 100644 index 0000000000..62bb6b6372 --- /dev/null +++ b/pkg/common/containerinfo/testdata/other/mountinfo-fallback/proc/123/cgroup @@ -0,0 +1 @@ +0::/ \ No newline at end of file diff --git a/pkg/common/containerinfo/testdata/other/mountinfo-fallback/proc/123/mountinfo b/pkg/common/containerinfo/testdata/other/mountinfo-fallback/proc/123/mountinfo new file mode 100644 index 0000000000..a51c18f768 --- /dev/null +++ b/pkg/common/containerinfo/testdata/other/mountinfo-fallback/proc/123/mountinfo @@ -0,0 +1 @@ +1543 1542 0:32 /../../kubelet-kubepods-besteffort.slice/kubelet-kubepods-besteffort-pod00000000_1111_2222_3333_444444444444.slice/cri-containerd-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.scope /sys/fs/cgroup ro,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw,nsdelegate,memory_recursiveprot diff --git a/pkg/common/containerinfo/testdata/other/mountinfo-impersonation/proc/123/cgroup b/pkg/common/containerinfo/testdata/other/mountinfo-impersonation/proc/123/cgroup new file mode 100644 index 0000000000..95b868c8a1 --- /dev/null +++ b/pkg/common/containerinfo/testdata/other/mountinfo-impersonation/proc/123/cgroup @@ -0,0 +1 @@ +0::/../0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \ No newline at end of file diff --git a/pkg/common/containerinfo/testdata/other/mountinfo-impersonation/proc/123/mountinfo b/pkg/common/containerinfo/testdata/other/mountinfo-impersonation/proc/123/mountinfo new file mode 100644 index 0000000000..cd3765002b --- /dev/null +++ b/pkg/common/containerinfo/testdata/other/mountinfo-impersonation/proc/123/mountinfo @@ -0,0 +1 @@ +1543 1542 0:32 /../../kubelet-kubepods-besteffort.slice/kubelet-kubepods-besteffort-podffffffff_ffff_ffff_ffff_ffffffffffff.slice/cri-containerd-deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef.scope /sys/fs/cgroup ro,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw,nsdelegate,memory_recursiveprot \ No newline at end of file diff --git a/pkg/common/idutil/safety_test.go b/pkg/common/idutil/safety_test.go index f2d9379178..8ec1f4db9b 100644 --- a/pkg/common/idutil/safety_test.go +++ b/pkg/common/idutil/safety_test.go @@ -64,29 +64,64 @@ func TestIDProtoFromString(t *testing.T) { } func TestIDFromProto(t *testing.T) { - assert := assert.New(t) - - id, err := IDFromProto(&types.SPIFFEID{}) - assert.EqualError(err, "trust domain is missing") - assert.Empty(id) - - id, err = IDFromProto(&types.SPIFFEID{TrustDomain: "example.org"}) - assert.NoError(err) - assert.Equal("spiffe://example.org", id.String()) - - id, err = IDFromProto(&types.SPIFFEID{TrustDomain: "example.org", Path: "/"}) - assert.EqualError(err, "path cannot have a trailing slash") - assert.Empty(id) - - id, err = IDFromProto(&types.SPIFFEID{TrustDomain: "example.org", Path: "workload"}) - assert.EqualError(err, "path must have a leading slash") - assert.Empty(id) - - id, err = IDFromProto(&types.SPIFFEID{TrustDomain: "example.org", Path: "/workload"}) - assert.NoError(err) - assert.Equal("spiffe://example.org/workload", id.String()) - - id, err = IDFromProto(&types.SPIFFEID{TrustDomain: "example.org", Path: "/workload/%41%42%43"}) - assert.EqualError(err, "path segment characters are limited to letters, numbers, dots, dashes, and underscores") - assert.Empty(id) + for _, tt := range []struct { + name string + proto *types.SPIFFEID + expected string + expectErr string + }{ + { + name: "empty proto", + proto: &types.SPIFFEID{}, + expectErr: "trust domain is missing", + }, + { + name: "valid trust domain", + proto: &types.SPIFFEID{TrustDomain: "example.org"}, + expected: "spiffe://example.org", + }, + { + name: "valid trust domain and path", + proto: &types.SPIFFEID{TrustDomain: "example.org", Path: "/workload"}, + expected: "spiffe://example.org/workload", + }, + { + name: "trailing slash in path", + proto: &types.SPIFFEID{TrustDomain: "example.org", Path: "/"}, + expectErr: "path cannot have a trailing slash", + }, + { + name: "missing leading slash in path", + proto: &types.SPIFFEID{TrustDomain: "example.org", Path: "workload"}, + expectErr: "path must have a leading slash", + }, + { + name: "invalid characters in path", + proto: &types.SPIFFEID{TrustDomain: "example.org", Path: "/workload/%41%42%43"}, + expectErr: "path segment characters are limited to letters, numbers, dots, dashes, and underscores", + }, + { + name: "invalid characters in trust domain", + proto: &types.SPIFFEID{TrustDomain: "example.org/path"}, + expectErr: "trust domain characters are limited to lowercase letters, numbers, dots, dashes, and underscores", + }, + { + name: "trust domain with space", + proto: &types.SPIFFEID{TrustDomain: "example .org"}, + expectErr: "trust domain characters are limited to lowercase letters, numbers, dots, dashes, and underscores", + }, + } { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + + id, err := IDFromProto(tt.proto) + if tt.expectErr != "" { + assert.EqualError(err, tt.expectErr) + assert.Empty(id) + return + } + assert.NoError(err) + assert.Equal(tt.expected, id.String()) + }) + } } diff --git a/pkg/common/observer/observer.go b/pkg/common/observer/observer.go new file mode 100644 index 0000000000..2b3aa81736 --- /dev/null +++ b/pkg/common/observer/observer.go @@ -0,0 +1,123 @@ +package observer + +import "sync" + +// Property stores a value and allows observers to subscribe to updates. +type Property interface { + Value() any + Update(any) + Observe() Stream +} + +// Stream observes updates to a Property. +type Stream interface { + Value() any + Changes() chan struct{} + Next() any + WaitNext() any + HasNext() bool + Clone() Stream +} + +type property struct { + mu sync.Mutex + state *state +} + +type stream struct { + property *property + state *state +} + +type state struct { + value any + next *state + done chan struct{} +} + +// NewProperty creates a property with the provided initial value. +func NewProperty(value any) Property { + return &property{ + state: newState(value), + } +} + +func (p *property) Value() any { + p.mu.Lock() + defer p.mu.Unlock() + + return p.state.value +} + +func (p *property) Update(value any) { + p.mu.Lock() + defer p.mu.Unlock() + + next := newState(value) + p.state.next = next + close(p.state.done) + p.state = next +} + +func (p *property) Observe() Stream { + p.mu.Lock() + defer p.mu.Unlock() + + return &stream{ + property: p, + state: p.state, + } +} + +func (s *stream) Value() any { + s.property.mu.Lock() + defer s.property.mu.Unlock() + + return s.state.value +} + +func (s *stream) Changes() chan struct{} { + s.property.mu.Lock() + defer s.property.mu.Unlock() + + return s.state.done +} + +func (s *stream) Next() any { + s.property.mu.Lock() + defer s.property.mu.Unlock() + + if s.state.next != nil { + s.state = s.state.next + } + return s.state.value +} + +func (s *stream) WaitNext() any { + <-s.Changes() + return s.Next() +} + +func (s *stream) HasNext() bool { + s.property.mu.Lock() + defer s.property.mu.Unlock() + + return s.state.next != nil +} + +func (s *stream) Clone() Stream { + s.property.mu.Lock() + defer s.property.mu.Unlock() + + return &stream{ + property: s.property, + state: s.state, + } +} + +func newState(value any) *state { + return &state{ + value: value, + done: make(chan struct{}), + } +} diff --git a/pkg/common/observer/observer_test.go b/pkg/common/observer/observer_test.go new file mode 100644 index 0000000000..76aadeb442 --- /dev/null +++ b/pkg/common/observer/observer_test.go @@ -0,0 +1,132 @@ +package observer + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPropertyStreamObservesUpdate(t *testing.T) { + property := NewProperty("initial") + stream := property.Observe() + + assert.Equal(t, "initial", property.Value()) + assert.Equal(t, "initial", stream.Value()) + assert.False(t, stream.HasNext()) + + property.Update("updated") + + requireChange(t, stream.Changes()) + assert.True(t, stream.HasNext()) + assert.Equal(t, "updated", stream.Next()) + assert.Equal(t, "updated", stream.Value()) + assert.Equal(t, "updated", property.Value()) + assert.False(t, stream.HasNext()) +} + +func TestWaitNextBlocksUntilUpdate(t *testing.T) { + property := NewProperty("initial") + stream := property.Observe() + + ready := make(chan struct{}) + result := make(chan any, 1) + go func() { + close(ready) + result <- stream.WaitNext() + }() + + <-ready + select { + case value := <-result: + t.Fatalf("WaitNext returned before update: %v", value) + case <-time.After(20 * time.Millisecond): + } + + property.Update("updated") + + select { + case value := <-result: + assert.Equal(t, "updated", value) + case <-time.After(time.Second): + t.Fatal("timed out waiting for WaitNext") + } +} + +func TestStreamDeliversSequentialUpdatesInOrder(t *testing.T) { + property := NewProperty(0) + stream := property.Observe() + + property.Update(1) + property.Update(2) + property.Update(3) + + for _, expected := range []int{1, 2, 3} { + require.True(t, stream.HasNext()) + requireChange(t, stream.Changes()) + assert.Equal(t, expected, stream.Next()) + assert.Equal(t, expected, stream.Value()) + } + assert.False(t, stream.HasNext()) + requireNoChange(t, stream.Changes()) +} + +func TestIndependentStreamsObserveCurrentValue(t *testing.T) { + property := NewProperty("initial") + streamA := property.Observe() + + property.Update("current") + streamB := property.Observe() + + assert.Equal(t, "initial", streamA.Value()) + assert.True(t, streamA.HasNext()) + assert.Equal(t, "current", streamB.Value()) + assert.False(t, streamB.HasNext()) + + assert.Equal(t, "current", streamA.Next()) + property.Update("next") + + assert.Equal(t, "next", streamA.Next()) + assert.Equal(t, "next", streamB.Next()) + assert.Equal(t, "next", streamA.Value()) + assert.Equal(t, "next", streamB.Value()) +} + +func TestStreamCloneCopiesCursor(t *testing.T) { + property := NewProperty("initial") + stream := property.Observe() + + property.Update("first") + clone := stream.Clone() + + assert.Equal(t, "initial", stream.Value()) + assert.Equal(t, "initial", clone.Value()) + assert.Equal(t, "first", stream.Next()) + assert.Equal(t, "first", clone.Next()) + + property.Update("second") + + assert.Equal(t, "second", stream.Next()) + assert.Equal(t, "second", clone.Next()) +} + +func requireChange(t *testing.T, changes chan struct{}) { + t.Helper() + + select { + case <-changes: + case <-time.After(time.Second): + t.Fatal("timed out waiting for change") + } +} + +func requireNoChange(t *testing.T, changes chan struct{}) { + t.Helper() + + select { + case <-changes: + t.Fatal("unexpected change") + case <-time.After(20 * time.Millisecond): + } +} diff --git a/pkg/common/ratelimit/limiter.go b/pkg/common/ratelimit/limiter.go new file mode 100644 index 0000000000..c80de8115f --- /dev/null +++ b/pkg/common/ratelimit/limiter.go @@ -0,0 +1,17 @@ +package ratelimit + +import ( + "context" + "time" + + "golang.org/x/time/rate" +) + +// Limiter covers both blocking (WaitN) and non-blocking (AllowN) rate limiting. +// *rate.Limiter satisfies this interface. +type Limiter interface { + AllowN(now time.Time, n int) bool + WaitN(ctx context.Context, n int) error + Limit() rate.Limit + Burst() int +} diff --git a/pkg/common/ratelimit/perkeylimiter.go b/pkg/common/ratelimit/perkeylimiter.go new file mode 100644 index 0000000000..fe0b9abec4 --- /dev/null +++ b/pkg/common/ratelimit/perkeylimiter.go @@ -0,0 +1,106 @@ +package ratelimit + +import ( + "sync" + "time" + + "github.com/andres-erbsen/clock" +) + +const ( + // GCInterval is the interval at which per-key limiters are garbage + // collected. Keys not accessed within two GC intervals are reclaimed. + GCInterval = time.Minute +) + +// PerKeyLimiter maintains per-key rate limiters using a two-generation garbage +// collection pattern. Keys that are not accessed within two GC intervals have +// their limiters reclaimed. +type PerKeyLimiter struct { + newLimiter func() Limiter + clock clock.Clock + + mtx sync.RWMutex + + // previous holds all the limiters that were current at the last GC. + previous map[string]Limiter + + // current holds all the limiters that have been created or promoted + // from previous since the last GC. + current map[string]Limiter + + // lastGC is the time of the last garbage collection. + lastGC time.Time +} + +// Option configures a PerKeyLimiter. +type Option func(*PerKeyLimiter) + +// WithClock sets the clock used for time-based operations. Useful for testing. +func WithClock(c clock.Clock) Option { + return func(l *PerKeyLimiter) { + l.clock = c + } +} + +// NewPerKeyLimiter creates a new PerKeyLimiter with the given limiter factory +// and options. +func NewPerKeyLimiter(newLimiter func() Limiter, opts ...Option) *PerKeyLimiter { + l := &PerKeyLimiter{ + newLimiter: newLimiter, + clock: clock.New(), + current: make(map[string]Limiter), + } + for _, opt := range opts { + opt(l) + } + l.lastGC = l.clock.Now() + return l +} + +// Now returns the current time according to the configured clock. +func (l *PerKeyLimiter) Now() time.Time { + return l.clock.Now() +} + +// GetLimiter returns the rate limiter for the given key, creating one if +// necessary and performing garbage collection as needed. +func (l *PerKeyLimiter) GetLimiter(key string) Limiter { + l.mtx.RLock() + limiter, ok := l.current[key] + if ok { + l.mtx.RUnlock() + return limiter + } + l.mtx.RUnlock() + + // A limiter does not exist for that key. + l.mtx.Lock() + defer l.mtx.Unlock() + + // Check the "current" entries in case another goroutine raced on this key. + if limiter, ok = l.current[key]; ok { + return limiter + } + + // Then check the "previous" entries to see if a limiter exists for this + // key as of the last GC. If so, move it to current and return it. + if limiter, ok = l.previous[key]; ok { + l.current[key] = limiter + delete(l.previous, key) + return limiter + } + + // There is no limiter for this key. Before we create one, we should see + // if we need to do GC. + now := l.clock.Now() + if now.Sub(l.lastGC) >= GCInterval { + l.previous = l.current + l.current = make(map[string]Limiter) + l.lastGC = now + } + + limiter = l.newLimiter() + l.current[key] = limiter + return limiter +} diff --git a/pkg/common/ratelimit/perkeylimiter_test.go b/pkg/common/ratelimit/perkeylimiter_test.go new file mode 100644 index 0000000000..58a74037b2 --- /dev/null +++ b/pkg/common/ratelimit/perkeylimiter_test.go @@ -0,0 +1,123 @@ +package ratelimit + +import ( + "fmt" + "sync" + "testing" + "time" + + testclock "github.com/spiffe/spire/test/clock" + "github.com/stretchr/testify/assert" + "golang.org/x/time/rate" +) + +func newTestLimiterFactory(limit int) func() Limiter { + return func() Limiter { + return rate.NewLimiter(rate.Limit(limit), limit) + } +} + +func TestPerKeyLimiterBasic(t *testing.T) { + l := NewPerKeyLimiter(newTestLimiterFactory(2)) + + lim := l.GetLimiter("key1") + // First two events are allowed (burst=2). + assert.True(t, lim.AllowN(time.Now(), 1)) + assert.True(t, lim.AllowN(time.Now(), 1)) + // Third event is denied. + assert.False(t, lim.AllowN(time.Now(), 1)) +} + +func TestPerKeyLimiterIndependence(t *testing.T) { + l := NewPerKeyLimiter(newTestLimiterFactory(1)) + + // Different keys have independent token buckets. + lim1 := l.GetLimiter("key1") + lim2 := l.GetLimiter("key2") + assert.True(t, lim1.AllowN(time.Now(), 1)) + assert.True(t, lim2.AllowN(time.Now(), 1)) + // Both keys are now exhausted. + assert.False(t, lim1.AllowN(time.Now(), 1)) + assert.False(t, lim2.AllowN(time.Now(), 1)) +} + +func TestPerKeyLimiterSameKeyReturnsSameLimiter(t *testing.T) { + l := NewPerKeyLimiter(newTestLimiterFactory(1)) + + lim1 := l.GetLimiter("key1") + lim2 := l.GetLimiter("key1") + assert.Same(t, lim1, lim2) +} + +func TestPerKeyLimiterGC(t *testing.T) { + mockClk := testclock.NewMock(t) + l := NewPerKeyLimiter(newTestLimiterFactory(1), WithClock(mockClk)) + + // Create and exhaust key1. + lim := l.GetLimiter("key1") + assert.True(t, lim.AllowN(mockClk.Now(), 1)) + assert.False(t, lim.AllowN(mockClk.Now(), 1)) + + // Advance past the GC interval and trigger GC by accessing key2. + // key1 moves from current to previous. + mockClk.Add(GCInterval) + l.GetLimiter("key2") + + // Advance past the GC interval again and trigger GC by accessing key3. + // key1 (in previous) is dropped entirely. + mockClk.Add(GCInterval) + l.GetLimiter("key3") + + // key1 has been GC'd. A new limiter is created with a fresh token bucket. + lim = l.GetLimiter("key1") + assert.True(t, lim.AllowN(mockClk.Now(), 1)) +} + +func TestPerKeyLimiterPreviousPreservation(t *testing.T) { + mockClk := testclock.NewMock(t) + l := NewPerKeyLimiter(newTestLimiterFactory(1), WithClock(mockClk)) + + // Create limiters for key1 and key2. + l.GetLimiter("key1") + l.GetLimiter("key2") + + // Advance past GC interval and trigger GC via key3. + // key1 and key2 move to previous. + mockClk.Add(GCInterval) + l.GetLimiter("key3") + + // Access key1 - it gets promoted from previous to current (same limiter). + lim1Before := l.GetLimiter("key1") + + // Advance past GC interval again and trigger GC via key4. + // key2 (in previous) is dropped. key1 and key3 move to previous. + mockClk.Add(GCInterval) + l.GetLimiter("key4") + + // key1 was promoted so it's still alive (moved to previous, not dropped). + lim1After := l.GetLimiter("key1") + assert.Same(t, lim1Before, lim1After) + + // key2 is gone; accessing it creates a fresh limiter. + lim2 := l.GetLimiter("key2") + assert.True(t, lim2.AllowN(mockClk.Now(), 1)) +} + +func TestPerKeyLimiterConcurrency(t *testing.T) { + const goroutines = 50 + const callsPerGoroutine = 200 + + l := NewPerKeyLimiter(newTestLimiterFactory(10)) + + var wg sync.WaitGroup + for i := range goroutines { + wg.Go(func() { + key := fmt.Sprintf("key:%d", i%5) + for range callsPerGoroutine { + lim := l.GetLimiter(key) + lim.AllowN(time.Now(), 1) + } + }) + } + wg.Wait() +} diff --git a/pkg/common/telemetry/agent/adminapi/loggerapi.go b/pkg/common/telemetry/agent/adminapi/loggerapi.go new file mode 100644 index 0000000000..d65d7ea853 --- /dev/null +++ b/pkg/common/telemetry/agent/adminapi/loggerapi.go @@ -0,0 +1,13 @@ +package adminapi + +import ( + "github.com/spiffe/spire/pkg/common/telemetry" +) + +func IncrLoggerAPIConnectionCounter(m telemetry.Metrics) { + m.IncrCounter([]string{telemetry.LoggerAPI, telemetry.Connection}, 1) +} + +func SetLoggerAPIConnectionGauge(m telemetry.Metrics, connections int32) { + m.SetGauge([]string{telemetry.LoggerAPI, telemetry.Connections}, float32(connections)) +} diff --git a/pkg/common/telemetry/agent/workloadapi/workload.go b/pkg/common/telemetry/agent/workloadapi/workload.go index 515abb947a..0bef44bead 100644 --- a/pkg/common/telemetry/agent/workloadapi/workload.go +++ b/pkg/common/telemetry/agent/workloadapi/workload.go @@ -37,6 +37,18 @@ func SetConnectionTotalGauge(m telemetry.Metrics, connections int32) { m.SetGauge([]string{telemetry.WorkloadAPI, telemetry.Connections}, float32(connections)) } +// IncrRateLimitExceededCounter records a Workload API rate limit rejection. +// method is the full gRPC method name. +func IncrRateLimitExceededCounter(m telemetry.Metrics, method string) { + m.IncrCounterWithLabels( + []string{telemetry.WorkloadAPI, telemetry.RateLimitExceeded}, + 1, + []telemetry.Label{ + {Name: telemetry.Method, Value: method}, + }, + ) +} + // End Counters // Add Samples (metric on count of some object, entries, event...) diff --git a/pkg/common/telemetry/config.go b/pkg/common/telemetry/config.go index f486270929..3892b13ccb 100644 --- a/pkg/common/telemetry/config.go +++ b/pkg/common/telemetry/config.go @@ -1,16 +1,24 @@ package telemetry import ( + "crypto/x509" + "github.com/hashicorp/hcl/hcl/token" "github.com/sirupsen/logrus" + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/go-spiffe/v2/svid/x509svid" + "github.com/spiffe/spire/pkg/common/tlspolicy" ) type MetricsConfig struct { - FileConfig FileConfig - Logger logrus.FieldLogger - ServiceName string - Sinks []Sink - TrustDomain string + FileConfig FileConfig + Logger logrus.FieldLogger + ServiceName string + Sinks []Sink + TrustDomain string + TLSPolicy tlspolicy.Policy + GetX509SVID func() (*x509svid.SVID, error) + GetX509BundleAuthorities func(spiffeid.TrustDomain) ([]*x509.Certificate, error) } type FileConfig struct { @@ -44,9 +52,11 @@ type PrometheusConfig struct { } type TLSConfig struct { - CertFile string `hcl:"cert_file"` - KeyFile string `hcl:"key_file"` - ClientCAFile string `hcl:"client_ca_file"` // optional + CertFile string `hcl:"cert_file"` + KeyFile string `hcl:"key_file"` + ClientCAFile string `hcl:"client_ca_file"` // optional + UseSPIRESVID bool `hcl:"use_spire_svid"` + AuthorizedSPIFFEIDs []string `hcl:"authorized_spiffe_ids"` } type StatsdConfig struct { diff --git a/pkg/common/telemetry/names.go b/pkg/common/telemetry/names.go index 230d83f1d3..d1281b2730 100644 --- a/pkg/common/telemetry/names.go +++ b/pkg/common/telemetry/names.go @@ -402,6 +402,9 @@ const ( // LocalAuthorityID tags a local authority ID LocalAuthorityID = "local_authority_id" + // LoggerAPI functionality related to logger endpoints + LoggerAPI = "logger_api" + // Mode tags a bundle deletion mode Mode = "mode" @@ -459,6 +462,9 @@ const ( // Pruned flagging something has been pruned Pruned = "pruned" + // RateLimitExceeded tags a rate limit exceeded event + RateLimitExceeded = "rate_limit_exceeded" + // ReadOnly tags something read-only ReadOnly = "read_only" @@ -487,6 +493,9 @@ const ( // Reconfigurable tags whether something is reconfigurable. Reconfigurable = "reconfigurable" + // ReferenceType tags a workload reference type URL + ReferenceType = "reference_type" + // RefreshHint tags a bundle refresh hint RefreshHint = "refresh_hint" @@ -846,6 +855,9 @@ const ( // AuthorizeCall functionality related to authorizing an incoming call AuthorizeCall = "authorize_call" + // BrokerAPI functionality related to broker endpoints + BrokerAPI = "broker_api" + // CreateFederatedBundle functionality related to creating a federated bundle CreateFederatedBundle = "create_federated_bundle" diff --git a/pkg/common/telemetry/prometheus.go b/pkg/common/telemetry/prometheus.go index ba3d544084..3142b75694 100644 --- a/pkg/common/telemetry/prometheus.go +++ b/pkg/common/telemetry/prometheus.go @@ -14,20 +14,31 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/sirupsen/logrus" + "github.com/spiffe/go-spiffe/v2/bundle/x509bundle" + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig" + "github.com/spiffe/go-spiffe/v2/svid/x509svid" + "github.com/spiffe/spire/pkg/common/tlspolicy" "github.com/spiffe/spire/pkg/common/util" ) type prometheusRunner struct { - c *PrometheusConfig - log logrus.FieldLogger - server *http.Server - sink Sink + c *PrometheusConfig + log logrus.FieldLogger + server *http.Server + sink Sink + tlsPolicy tlspolicy.Policy + getX509SVID func() (*x509svid.SVID, error) + getX509BundleAuthorities func(spiffeid.TrustDomain) ([]*x509.Certificate, error) } func newPrometheusRunner(c *MetricsConfig) (sinkRunner, error) { runner := &prometheusRunner{ - c: c.FileConfig.Prometheus, - log: c.Logger, + c: c.FileConfig.Prometheus, + log: c.Logger, + tlsPolicy: c.TLSPolicy, + getX509SVID: c.GetX509SVID, + getX509BundleAuthorities: c.GetX509BundleAuthorities, } if runner.c == nil { @@ -69,6 +80,9 @@ func newPrometheusRunner(c *MetricsConfig) (sinkRunner, error) { if tlsCfgErr != nil { return runner, fmt.Errorf("failed to create TLS config for Prometheus: %w", tlsCfgErr) } + if err := tlspolicy.ApplyPolicy(tlsCfg, runner.tlsPolicy); err != nil { + return runner, fmt.Errorf("failed to apply TLS policy for Prometheus: %w", err) + } runner.server.TLSConfig = tlsCfg } @@ -119,27 +133,118 @@ func (p *prometheusRunner) requiresTypePrefix() bool { } func (p *prometheusRunner) newTLSConfig() (*tls.Config, error) { - certificate, err := tls.LoadX509KeyPair(p.c.TLS.CertFile, p.c.TLS.KeyFile) - if err != nil { + if err := p.validateTLSConfig(); err != nil { return nil, err } - // easier to return the tls config rather than assigning it to the server directly from maintenance perspective - tlsCfg := &tls.Config{ - Certificates: []tls.Certificate{certificate}, - MinVersion: tls.VersionTLS12, + authorizedSPIFFEIDs, err := p.authorizedSPIFFEIDs() + if err != nil { + return nil, err } - var caCertPool *x509.CertPool - if p.c.TLS.ClientCAFile != "" { - caCertPool, err = util.LoadCertPool(p.c.TLS.ClientCAFile) + switch { + case p.c.TLS.UseSPIRESVID: + return p.newSPIFFETLSConfig(authorizedSPIFFEIDs), nil + case len(authorizedSPIFFEIDs) > 0: + certificate, err := tls.LoadX509KeyPair(p.c.TLS.CertFile, p.c.TLS.KeyFile) + if err != nil { + return nil, err + } + return p.newSPIFFEWebTLSConfig(&certificate, authorizedSPIFFEIDs), nil + default: + certificate, err := tls.LoadX509KeyPair(p.c.TLS.CertFile, p.c.TLS.KeyFile) if err != nil { return nil, err } - tlsCfg.ClientCAs = caCertPool - tlsCfg.ClientAuth = tls.RequireAndVerifyClientCert + // easier to return the tls config rather than assigning it to the server directly from maintenance perspective + tlsCfg := &tls.Config{ + Certificates: []tls.Certificate{certificate}, + MinVersion: tls.VersionTLS12, + } + + if p.c.TLS.ClientCAFile != "" { + caCertPool, err := util.LoadCertPool(p.c.TLS.ClientCAFile) + if err != nil { + return nil, err + } + + tlsCfg.ClientCAs = caCertPool + tlsCfg.ClientAuth = tls.RequireAndVerifyClientCert + } + + return tlsCfg, nil + } +} + +func (p *prometheusRunner) validateTLSConfig() error { + switch { + case p.c.TLS.UseSPIRESVID && (p.c.TLS.CertFile != "" || p.c.TLS.KeyFile != ""): + return errors.New("cert_file and key_file cannot be configured when use_spire_svid is enabled") + case !p.c.TLS.UseSPIRESVID && (p.c.TLS.CertFile == "" || p.c.TLS.KeyFile == ""): + return errors.New("cert_file and key_file must both be configured unless use_spire_svid is enabled") + case len(p.c.TLS.AuthorizedSPIFFEIDs) > 0 && p.c.TLS.ClientCAFile != "": + return errors.New("client_ca_file cannot be configured with authorized_spiffe_ids") + case p.c.TLS.UseSPIRESVID && p.getX509SVID == nil: + return errors.New("use_spire_svid requires access to the current SPIRE SVID") + case len(p.c.TLS.AuthorizedSPIFFEIDs) > 0 && p.getX509BundleAuthorities == nil: + return errors.New("authorized_spiffe_ids requires access to SPIRE trust bundles") + default: + return nil + } +} + +func (p *prometheusRunner) authorizedSPIFFEIDs() ([]spiffeid.ID, error) { + authorizedIDs := make([]spiffeid.ID, 0, len(p.c.TLS.AuthorizedSPIFFEIDs)) + for _, idString := range p.c.TLS.AuthorizedSPIFFEIDs { + id, err := spiffeid.FromString(idString) + if err != nil { + return nil, fmt.Errorf("invalid authorized SPIFFE ID %q: %w", idString, err) + } + authorizedIDs = append(authorizedIDs, id) + } + return authorizedIDs, nil +} + +func (p *prometheusRunner) newSPIFFETLSConfig(authorizedSPIFFEIDs []spiffeid.ID) *tls.Config { + svidSource := &telemetryX509SVIDSource{getter: p.getX509SVID} + tlsCfg := tlsconfig.TLSServerConfig(svidSource) + if len(authorizedSPIFFEIDs) > 0 { + bundleSource := &telemetryBundleSource{getter: p.getX509BundleAuthorities} + tlsCfg = tlsconfig.MTLSServerConfig(svidSource, bundleSource, tlsconfig.AuthorizeOneOf(authorizedSPIFFEIDs...)) + } + + tlsCfg.MinVersion = tls.VersionTLS12 + tlsCfg.SessionTicketsDisabled = true + return tlsCfg +} + +func (p *prometheusRunner) newSPIFFEWebTLSConfig(certificate *tls.Certificate, authorizedSPIFFEIDs []spiffeid.ID) *tls.Config { + bundleSource := &telemetryBundleSource{getter: p.getX509BundleAuthorities} + tlsCfg := tlsconfig.MTLSWebServerConfig(certificate, bundleSource, tlsconfig.AuthorizeOneOf(authorizedSPIFFEIDs...)) + tlsCfg.MinVersion = tls.VersionTLS12 + tlsCfg.SessionTicketsDisabled = true + return tlsCfg +} + +type telemetryX509SVIDSource struct { + getter func() (*x509svid.SVID, error) +} + +func (s *telemetryX509SVIDSource) GetX509SVID() (*x509svid.SVID, error) { + return s.getter() +} + +type telemetryBundleSource struct { + getter func(spiffeid.TrustDomain) ([]*x509.Certificate, error) +} + +func (s *telemetryBundleSource) GetX509BundleForTrustDomain(trustDomain spiffeid.TrustDomain) (*x509bundle.Bundle, error) { + authorities, err := s.getter(trustDomain) + if err != nil { + return nil, err } - return tlsCfg, nil + bundle := x509bundle.FromX509Authorities(trustDomain, authorities) + return bundle.GetX509BundleForTrustDomain(trustDomain) } diff --git a/pkg/common/telemetry/prometheus_test.go b/pkg/common/telemetry/prometheus_test.go index 13c670b507..b769fd4340 100644 --- a/pkg/common/telemetry/prometheus_test.go +++ b/pkg/common/telemetry/prometheus_test.go @@ -17,6 +17,9 @@ import ( prommetrics "github.com/hashicorp/go-metrics/prometheus" "github.com/prometheus/client_golang/prometheus" "github.com/sirupsen/logrus/hooks/test" + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/go-spiffe/v2/svid/x509svid" + "github.com/spiffe/spire/test/testca" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -254,6 +257,212 @@ func TestPrometheusTLSConfig(t *testing.T) { } } +func TestPrometheusSPIFFETLSConfig(t *testing.T) { + td := spiffeid.RequireTrustDomainFromString("example.org") + foreignTD := spiffeid.RequireTrustDomainFromString("foreign.example.org") + + serverCA := testca.New(t, td) + foreignCA := testca.New(t, foreignTD) + + serverSVID := serverCA.CreateX509SVID(spiffeid.RequireFromPath(td, "/spire/server")) + allowedClientSVID := foreignCA.CreateX509SVID(spiffeid.RequireFromPath(foreignTD, "/prometheus")) + disallowedClientSVID := serverCA.CreateX509SVID(spiffeid.RequireFromPath(td, "/other-client")) + + tests := []struct { + name string + setupConfig func(*MetricsConfig) + validateTLS func(t *testing.T, runner *prometheusRunner) + }{ + { + name: "use SPIRE SVID without client allowlist", + setupConfig: func(config *MetricsConfig) { + config.FileConfig.Prometheus.TLS = &TLSConfig{ + UseSPIRESVID: true, + } + config.GetX509SVID = func() (*x509svid.SVID, error) { + return serverSVID, nil + } + }, + validateTLS: func(t *testing.T, runner *prometheusRunner) { + require.NotNil(t, runner.server.TLSConfig) + certificate, err := runner.server.TLSConfig.GetCertificate(&tls.ClientHelloInfo{}) + require.NoError(t, err) + require.NotNil(t, certificate) + assert.Equal(t, serverSVID.Certificates[0].Raw, certificate.Certificate[0]) + assert.Equal(t, tls.NoClientCert, runner.server.TLSConfig.ClientAuth) + assert.True(t, runner.server.TLSConfig.SessionTicketsDisabled) + }, + }, + { + name: "use SPIRE SVID with authorized SPIFFE IDs", + setupConfig: func(config *MetricsConfig) { + config.FileConfig.Prometheus.TLS = &TLSConfig{ + UseSPIRESVID: true, + AuthorizedSPIFFEIDs: []string{allowedClientSVID.ID.String()}, + } + config.GetX509SVID = func() (*x509svid.SVID, error) { + return serverSVID, nil + } + config.GetX509BundleAuthorities = func(td spiffeid.TrustDomain) ([]*x509.Certificate, error) { + switch td { + case serverCA.X509Bundle().TrustDomain(): + return serverCA.X509Authorities(), nil + case foreignCA.X509Bundle().TrustDomain(): + return foreignCA.X509Authorities(), nil + default: + return nil, nil + } + } + }, + validateTLS: func(t *testing.T, runner *prometheusRunner) { + require.NotNil(t, runner.server.TLSConfig) + assert.Equal(t, tls.RequireAnyClientCert, runner.server.TLSConfig.ClientAuth) + assert.True(t, runner.server.TLSConfig.SessionTicketsDisabled) + require.NoError(t, runner.server.TLSConfig.VerifyPeerCertificate(rawCerts(allowedClientSVID.Certificates), nil)) + require.Error(t, runner.server.TLSConfig.VerifyPeerCertificate(rawCerts(disallowedClientSVID.Certificates), nil)) + }, + }, + { + name: "use web certificate with authorized SPIFFE IDs", + setupConfig: func(config *MetricsConfig) { + certFile, keyFile := createTestCertAndKey(t) + config.FileConfig.Prometheus.TLS = &TLSConfig{ + CertFile: certFile, + KeyFile: keyFile, + AuthorizedSPIFFEIDs: []string{allowedClientSVID.ID.String()}, + } + config.GetX509BundleAuthorities = func(td spiffeid.TrustDomain) ([]*x509.Certificate, error) { + switch td { + case foreignCA.X509Bundle().TrustDomain(): + return foreignCA.X509Authorities(), nil + default: + return nil, nil + } + } + }, + validateTLS: func(t *testing.T, runner *prometheusRunner) { + require.NotNil(t, runner.server.TLSConfig) + assert.Len(t, runner.server.TLSConfig.Certificates, 1) + assert.Equal(t, tls.RequireAnyClientCert, runner.server.TLSConfig.ClientAuth) + assert.True(t, runner.server.TLSConfig.SessionTicketsDisabled) + require.NoError(t, runner.server.TLSConfig.VerifyPeerCertificate(rawCerts(allowedClientSVID.Certificates), nil)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := testPrometheusConfig() + tt.setupConfig(config) + + runner, err := newTestPrometheusRunner(config) + require.NoError(t, err) + require.NotNil(t, runner) + + tt.validateTLS(t, runner.(*prometheusRunner)) + }) + } +} + +func TestPrometheusTLSConfigValidation(t *testing.T) { + td := spiffeid.RequireTrustDomainFromString("example.org") + serverCA := testca.New(t, td) + serverSVID := serverCA.CreateX509SVID(spiffeid.RequireFromPath(td, "/spire/server")) + + tests := []struct { + name string + setupConfig func(*MetricsConfig) + errorMsgContains string + }{ + { + name: "use SPIRE SVID with cert file", + setupConfig: func(config *MetricsConfig) { + certFile, keyFile := createTestCertAndKey(t) + config.FileConfig.Prometheus.TLS = &TLSConfig{ + UseSPIRESVID: true, + CertFile: certFile, + KeyFile: keyFile, + } + config.GetX509SVID = func() (*x509svid.SVID, error) { + return serverSVID, nil + } + }, + errorMsgContains: "cert_file and key_file cannot be configured when use_spire_svid is enabled", + }, + { + name: "missing key pair without SPIRE SVID", + setupConfig: func(config *MetricsConfig) { + config.FileConfig.Prometheus.TLS = &TLSConfig{} + }, + errorMsgContains: "cert_file and key_file must both be configured unless use_spire_svid is enabled", + }, + { + name: "authorized SPIFFE IDs with client CA file", + setupConfig: func(config *MetricsConfig) { + certFile, keyFile := createTestCertAndKey(t) + caFile := createTestCA(t) + config.FileConfig.Prometheus.TLS = &TLSConfig{ + CertFile: certFile, + KeyFile: keyFile, + ClientCAFile: caFile, + AuthorizedSPIFFEIDs: []string{"spiffe://example.org/prometheus"}, + } + }, + errorMsgContains: "client_ca_file cannot be configured with authorized_spiffe_ids", + }, + { + name: "invalid authorized SPIFFE ID", + setupConfig: func(config *MetricsConfig) { + certFile, keyFile := createTestCertAndKey(t) + config.FileConfig.Prometheus.TLS = &TLSConfig{ + CertFile: certFile, + KeyFile: keyFile, + AuthorizedSPIFFEIDs: []string{"not-a-spiffe-id"}, + } + config.GetX509BundleAuthorities = func(td spiffeid.TrustDomain) ([]*x509.Certificate, error) { + return serverCA.X509Authorities(), nil + } + }, + errorMsgContains: `invalid authorized SPIFFE ID "not-a-spiffe-id"`, + }, + { + name: "use SPIRE SVID without SVID source", + setupConfig: func(config *MetricsConfig) { + config.FileConfig.Prometheus.TLS = &TLSConfig{ + UseSPIRESVID: true, + } + }, + errorMsgContains: "use_spire_svid requires access to the current SPIRE SVID", + }, + { + name: "authorized SPIFFE IDs without bundle source", + setupConfig: func(config *MetricsConfig) { + certFile, keyFile := createTestCertAndKey(t) + config.FileConfig.Prometheus.TLS = &TLSConfig{ + CertFile: certFile, + KeyFile: keyFile, + AuthorizedSPIFFEIDs: []string{"spiffe://example.org/prometheus"}, + } + }, + errorMsgContains: "authorized_spiffe_ids requires access to SPIRE trust bundles", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := testPrometheusConfig() + tt.setupConfig(config) + + runner, err := newTestPrometheusRunner(config) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsgContains) + if runner != nil { + assert.Nil(t, runner.(*prometheusRunner).server.TLSConfig) + } + }) + } +} + // createTestCertAndKey creates a temporary self-signed certificate and private key file // and returns the paths to both files func createTestCertAndKey(t *testing.T) (string, string) { @@ -347,3 +556,11 @@ func createTempFile(t *testing.T, content []byte) string { require.NoError(t, err) return tmpFilePath } + +func rawCerts(certs []*x509.Certificate) [][]byte { + raw := make([][]byte, 0, len(certs)) + for _, cert := range certs { + raw = append(raw, cert.Raw) + } + return raw +} diff --git a/pkg/common/telemetry/server/datastore/wrapper.go b/pkg/common/telemetry/server/datastore/wrapper.go index 14bc4244e3..40667e818f 100644 --- a/pkg/common/telemetry/server/datastore/wrapper.go +++ b/pkg/common/telemetry/server/datastore/wrapper.go @@ -131,6 +131,12 @@ func (w metricsWrapper) FetchAttestedNode(ctx context.Context, spiffeID string) return w.ds.FetchAttestedNode(ctx, spiffeID) } +func (w metricsWrapper) FetchAttestedNodes(ctx context.Context, spiffeIDs []string) (_ map[string]*common.AttestedNode, err error) { + callCounter := StartFetchNodeCall(w.m) + defer callCounter.Done(&err) + return w.ds.FetchAttestedNodes(ctx, spiffeIDs) +} + func (w metricsWrapper) FetchAttestedNodeEvent(ctx context.Context, eventID uint) (_ *datastore.AttestedNodeEvent, err error) { callCounter := StartFetchAttestedNodeEventCall(w.m) defer callCounter.Done(&err) @@ -263,10 +269,10 @@ func (w metricsWrapper) PruneRegistrationEntryEvents(ctx context.Context, olderT return w.ds.PruneRegistrationEntryEvents(ctx, olderThan) } -func (w metricsWrapper) PruneAttestedExpiredNodes(ctx context.Context, expiredBefore time.Time, includeNonReattestable bool) (err error) { +func (w metricsWrapper) PruneAttestedExpiredNodes(ctx context.Context, expiredBefore time.Time, includeNonReattestable bool, batchSize int) (err error) { callCounter := StartPruneAttestedExpiredNodes(w.m) defer callCounter.Done(&err) - return w.ds.PruneAttestedExpiredNodes(ctx, expiredBefore, includeNonReattestable) + return w.ds.PruneAttestedExpiredNodes(ctx, expiredBefore, includeNonReattestable, batchSize) } func (w metricsWrapper) SetBundle(ctx context.Context, bundle *common.Bundle) (_ *common.Bundle, err error) { diff --git a/pkg/common/telemetry/server/datastore/wrapper_test.go b/pkg/common/telemetry/server/datastore/wrapper_test.go index b5ac06d865..d190c15eec 100644 --- a/pkg/common/telemetry/server/datastore/wrapper_test.go +++ b/pkg/common/telemetry/server/datastore/wrapper_test.go @@ -117,6 +117,10 @@ func TestWithMetrics(t *testing.T) { key: "datastore.node.fetch", methodName: "FetchAttestedNode", }, + { + key: "datastore.node.fetch", + methodName: "FetchAttestedNodes", + }, { key: "datastore.node_event.fetch", methodName: "FetchAttestedNodeEvent", @@ -392,7 +396,7 @@ func (ds *fakeDataStore) DeleteAttestedNode(context.Context, string) (*common.At return &common.AttestedNode{}, ds.err } -func (ds *fakeDataStore) PruneAttestedExpiredNodes(context.Context, time.Time, bool) error { +func (ds *fakeDataStore) PruneAttestedExpiredNodes(context.Context, time.Time, bool, int) error { return ds.err } @@ -424,6 +428,10 @@ func (ds *fakeDataStore) FetchAttestedNode(context.Context, string) (*common.Att return &common.AttestedNode{}, ds.err } +func (ds *fakeDataStore) FetchAttestedNodes(context.Context, []string) (map[string]*common.AttestedNode, error) { + return map[string]*common.AttestedNode{}, ds.err +} + func (ds *fakeDataStore) FetchAttestedNodeEvent(context.Context, uint) (*datastore.AttestedNodeEvent, error) { return &datastore.AttestedNodeEvent{}, ds.err } diff --git a/pkg/common/tlspolicy/tlspolicy.go b/pkg/common/tlspolicy/tlspolicy.go index 2dd0ab9f52..fd7c14a853 100644 --- a/pkg/common/tlspolicy/tlspolicy.go +++ b/pkg/common/tlspolicy/tlspolicy.go @@ -34,6 +34,8 @@ func ApplyPolicy(config *tls.Config, policy Policy) error { // List only known PQ-safe KEMs as valid curves. config.CurvePreferences = []tls.CurveID{ tls.X25519MLKEM768, + tls.SecP256r1MLKEM768, + tls.SecP384r1MLKEM1024, } // Require TLS 1.3, as all PQ-safe KEMs require it anyway. diff --git a/pkg/common/tlspolicy/tlspolicy_test.go b/pkg/common/tlspolicy/tlspolicy_test.go index ec9776c913..28acb3c4d2 100644 --- a/pkg/common/tlspolicy/tlspolicy_test.go +++ b/pkg/common/tlspolicy/tlspolicy_test.go @@ -30,6 +30,6 @@ func TestApplyPolicy(t *testing.T) { }) require.NoError(err) - require.Equal(tlsConfig.CurvePreferences, []tls.CurveID{tls.X25519MLKEM768}) - require.Equal(tlsConfig.MinVersion, uint16(tls.VersionTLS13)) + require.Equal([]tls.CurveID{tls.X25519MLKEM768, tls.SecP256r1MLKEM768, tls.SecP384r1MLKEM1024}, tlsConfig.CurvePreferences) + require.Equal(uint16(tls.VersionTLS13), tlsConfig.MinVersion) } diff --git a/pkg/common/x509util/cert_test.go b/pkg/common/x509util/cert_test.go index 72cae6b90a..65fcdc09d8 100644 --- a/pkg/common/x509util/cert_test.go +++ b/pkg/common/x509util/cert_test.go @@ -69,3 +69,134 @@ func TestIsSignedByRoot(t *testing.T) { testSignedByRoot(t, svid1.Certificates, nil, false, "") testSignedByRoot(t, invalidCertificate, ca1.X509Authorities(), false, "failed to verify certificate chain: x509: certificate has expired or is not yet valid") } + +func TestRawCertsToCertificates(t *testing.T) { + td := spiffeid.RequireTrustDomainFromString("example.org") + ca := testca.New(t, td) + svid := ca.CreateX509SVID(spiffeid.RequireFromPath(td, "/w1")) + cert := svid.Certificates[0] + + t.Run("valid certificates", func(t *testing.T) { + rawCerts := [][]byte{cert.Raw} + certs, err := x509util.RawCertsToCertificates(rawCerts) + require.NoError(t, err) + require.Len(t, certs, 1) + assert.Equal(t, cert.Raw, certs[0].Raw) + }) + + t.Run("invalid certificate", func(t *testing.T) { + rawCerts := [][]byte{cert.Raw, []byte("invalid")} + certs, err := x509util.RawCertsToCertificates(rawCerts) + require.Error(t, err) + assert.Nil(t, certs) + }) + + t.Run("empty input", func(t *testing.T) { + certs, err := x509util.RawCertsToCertificates([][]byte{}) + require.NoError(t, err) + assert.Empty(t, certs) + }) + + t.Run("nil input", func(t *testing.T) { + certs, err := x509util.RawCertsToCertificates(nil) + require.NoError(t, err) + assert.Nil(t, certs) + }) +} + +func TestRawCertsFromCertificates(t *testing.T) { + td := spiffeid.RequireTrustDomainFromString("example.org") + ca := testca.New(t, td) + svid := ca.CreateX509SVID(spiffeid.RequireFromPath(td, "/w1")) + cert := svid.Certificates[0] + + t.Run("valid certificates", func(t *testing.T) { + certs := []*x509.Certificate{cert} + rawCerts := x509util.RawCertsFromCertificates(certs) + require.Len(t, rawCerts, 1) + assert.Equal(t, cert.Raw, rawCerts[0]) + }) + + t.Run("empty input", func(t *testing.T) { + rawCerts := x509util.RawCertsFromCertificates([]*x509.Certificate{}) + assert.Empty(t, rawCerts) + }) + + t.Run("nil input", func(t *testing.T) { + rawCerts := x509util.RawCertsFromCertificates(nil) + assert.Nil(t, rawCerts) + }) +} + +func TestDedupeCertificates(t *testing.T) { + td := spiffeid.RequireTrustDomainFromString("example.org") + ca := testca.New(t, td) + cert1 := ca.CreateX509SVID(spiffeid.RequireFromPath(td, "/w1")).Certificates[0] + cert2 := ca.CreateX509SVID(spiffeid.RequireFromPath(td, "/w2")).Certificates[0] + + t.Run("no duplicates", func(t *testing.T) { + deduped := x509util.DedupeCertificates([]*x509.Certificate{cert1}, []*x509.Certificate{cert2}) + assert.Len(t, deduped, 2) + assert.Equal(t, cert1.Raw, deduped[0].Raw) + assert.Equal(t, cert2.Raw, deduped[1].Raw) + }) + + t.Run("with duplicates", func(t *testing.T) { + deduped := x509util.DedupeCertificates([]*x509.Certificate{cert1, cert2}, []*x509.Certificate{cert2, cert1}) + assert.Len(t, deduped, 2) + assert.Equal(t, cert1.Raw, deduped[0].Raw) + assert.Equal(t, cert2.Raw, deduped[1].Raw) + }) + + t.Run("empty bundles", func(t *testing.T) { + deduped := x509util.DedupeCertificates([]*x509.Certificate{}, nil) + assert.Empty(t, deduped) + }) +} + +func TestDERFromCertificates(t *testing.T) { + td := spiffeid.RequireTrustDomainFromString("example.org") + ca := testca.New(t, td) + cert1 := ca.CreateX509SVID(spiffeid.RequireFromPath(td, "/w1")).Certificates[0] + cert2 := ca.CreateX509SVID(spiffeid.RequireFromPath(td, "/w2")).Certificates[0] + + t.Run("multiple certificates", func(t *testing.T) { + der := x509util.DERFromCertificates([]*x509.Certificate{cert1, cert2}) + expected := append([]byte{}, cert1.Raw...) + expected = append(expected, cert2.Raw...) + assert.Equal(t, expected, der) + }) + + t.Run("nil input", func(t *testing.T) { + der := x509util.DERFromCertificates(nil) + assert.Nil(t, der) + }) +} + +func TestCreateCertificate(t *testing.T) { + caCert, caKey := testca.CreateCACertificate(t, nil, nil) + + template := &x509.Certificate{ + SerialNumber: caCert.SerialNumber, + } + + cert, err := x509util.CreateCertificate(template, caCert, caCert.PublicKey, caKey) + require.NoError(t, err) + require.NotNil(t, cert) + assert.Equal(t, caCert.RawSubject, cert.RawIssuer) +} + +func TestCertificateMatchesPrivateKey(t *testing.T) { + td := spiffeid.RequireTrustDomainFromString("example.org") + ca := testca.New(t, td) + svid := ca.CreateX509SVID(spiffeid.RequireFromPath(td, "/w1")) + _, otherKey := ca.CreateX509Certificate() + + matches, err := x509util.CertificateMatchesPrivateKey(svid.Certificates[0], svid.PrivateKey) + require.NoError(t, err) + assert.True(t, matches) + + matches, err = x509util.CertificateMatchesPrivateKey(svid.Certificates[0], otherKey) + require.NoError(t, err) + assert.False(t, matches) +} diff --git a/pkg/server/api/agent/v1/service.go b/pkg/server/api/agent/v1/service.go index ece654d6c8..2d4b344f94 100644 --- a/pkg/server/api/agent/v1/service.go +++ b/pkg/server/api/agent/v1/service.go @@ -13,6 +13,7 @@ import ( "github.com/spiffe/go-spiffe/v2/spiffeid" agentv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/server/agent/v1" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + commonapi "github.com/spiffe/spire/pkg/common/api" "github.com/spiffe/spire/pkg/common/errorutil" "github.com/spiffe/spire/pkg/common/idutil" "github.com/spiffe/spire/pkg/common/nodeutil" @@ -101,7 +102,7 @@ func (s *Service) CountAgents(ctx context.Context, req *agentv1.CountAgentsReque if filter.BySelectorMatch != nil { selectors, err := api.SelectorsFromProto(filter.BySelectorMatch.Selectors) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "failed to parse selectors", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "failed to parse selectors", err) } countReq.BySelectorMatch = &datastore.BySelectors{ Match: datastore.MatchBehavior(filter.BySelectorMatch.Match), @@ -113,7 +114,7 @@ func (s *Service) CountAgents(ctx context.Context, req *agentv1.CountAgentsReque count, err := s.ds.CountAttestedNodes(ctx, countReq) if err != nil { log := rpccontext.Logger(ctx) - return nil, api.MakeErr(log, codes.Internal, "failed to count agents", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to count agents", err) } rpccontext.AuditRPC(ctx) @@ -152,7 +153,7 @@ func (s *Service) ListAgents(ctx context.Context, req *agentv1.ListAgentsRequest if filter.BySelectorMatch != nil { selectors, err := api.SelectorsFromProto(filter.BySelectorMatch.Selectors) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "failed to parse selectors", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "failed to parse selectors", err) } listReq.BySelectorMatch = &datastore.BySelectors{ Match: datastore.MatchBehavior(filter.BySelectorMatch.Match), @@ -171,7 +172,7 @@ func (s *Service) ListAgents(ctx context.Context, req *agentv1.ListAgentsRequest dsResp, err := s.ds.ListAttestedNodes(ctx, listReq) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to list agents", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to list agents", err) } resp := &agentv1.ListAgentsResponse{} @@ -202,28 +203,28 @@ func (s *Service) GetAgent(ctx context.Context, req *agentv1.GetAgentRequest) (* agentID, err := api.TrustDomainAgentIDFromProto(ctx, s.td, req.Id) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "invalid agent ID", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "invalid agent ID", err) } rpccontext.AddRPCAuditFields(ctx, logrus.Fields{telemetry.SPIFFEID: agentID.String()}) log = log.WithField(telemetry.SPIFFEID, agentID.String()) attestedNode, err := s.ds.FetchAttestedNode(ctx, agentID.String()) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to fetch agent", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to fetch agent", err) } if attestedNode == nil { - return nil, api.MakeErr(log, codes.NotFound, "agent not found", err) + return nil, commonapi.MakeErr(log, codes.NotFound, "agent not found", err) } selectors, err := s.getSelectorsFromAgentID(ctx, attestedNode.SpiffeId) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to get selectors from agent", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to get selectors from agent", err) } agent, err := api.AttestedNodeToProto(attestedNode, selectors) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to convert attested node to agent", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to convert attested node to agent", err) } rpccontext.AuditRPC(ctx) @@ -237,7 +238,7 @@ func (s *Service) DeleteAgent(ctx context.Context, req *agentv1.DeleteAgentReque id, err := api.TrustDomainAgentIDFromProto(ctx, s.td, req.Id) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "invalid agent ID", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "invalid agent ID", err) } rpccontext.AddRPCAuditFields(ctx, logrus.Fields{telemetry.SPIFFEID: id.String()}) @@ -250,9 +251,9 @@ func (s *Service) DeleteAgent(ctx context.Context, req *agentv1.DeleteAgentReque rpccontext.AuditRPC(ctx) return &emptypb.Empty{}, nil case codes.NotFound: - return nil, api.MakeErr(log, codes.NotFound, "agent not found", err) + return nil, commonapi.MakeErr(log, codes.NotFound, "agent not found", err) default: - return nil, api.MakeErr(log, codes.Internal, "failed to remove agent", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to remove agent", err) } } @@ -262,7 +263,7 @@ func (s *Service) BanAgent(ctx context.Context, req *agentv1.BanAgentRequest) (* id, err := api.TrustDomainAgentIDFromProto(ctx, s.td, req.Id) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "invalid agent ID", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "invalid agent ID", err) } rpccontext.AddRPCAuditFields(ctx, logrus.Fields{telemetry.SPIFFEID: id.String()}) @@ -283,9 +284,9 @@ func (s *Service) BanAgent(ctx context.Context, req *agentv1.BanAgentRequest) (* rpccontext.AuditRPC(ctx) return &emptypb.Empty{}, nil case codes.NotFound: - return nil, api.MakeErr(log, codes.NotFound, "agent not found", err) + return nil, commonapi.MakeErr(log, codes.NotFound, "agent not found", err) default: - return nil, api.MakeErr(log, codes.Internal, "failed to ban agent", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to ban agent", err) } } @@ -295,18 +296,18 @@ func (s *Service) AttestAgent(stream agentv1.Agent_AttestAgentServer) error { log := rpccontext.Logger(ctx) if err := rpccontext.RateLimit(ctx, 1); err != nil { - return api.MakeErr(log, status.Code(err), "rejecting request due to attest agent rate limiting", err) + return commonapi.MakeErr(log, status.Code(err), "rejecting request due to attest agent rate limiting", err) } req, err := stream.Recv() if err != nil { - return api.MakeErr(log, codes.InvalidArgument, "failed to receive request from stream", err) + return commonapi.MakeErr(log, codes.InvalidArgument, "failed to receive request from stream", err) } // validate params := req.GetParams() if err := validateAttestAgentParams(params); err != nil { - return api.MakeErr(log, codes.InvalidArgument, "malformed param", err) + return commonapi.MakeErr(log, codes.InvalidArgument, "malformed param", err) } rpccontext.AddRPCAuditFields(ctx, logrus.Fields{ telemetry.NodeAttestorType: params.Data.Type, @@ -337,7 +338,7 @@ func (s *Service) AttestAgent(stream agentv1.Agent_AttestAgentServer) error { agentID, err := spiffeid.FromString(attestResult.AgentID) if err != nil { - return api.MakeErr(log, codes.Internal, "invalid agent ID", err) + return commonapi.MakeErr(log, codes.Internal, "invalid agent ID", err) } log = log.WithField(telemetry.AgentID, agentID) @@ -350,7 +351,7 @@ func (s *Service) AttestAgent(stream agentv1.Agent_AttestAgentServer) error { // Deprecated: enforce that IDs produced by Node Attestors are in the // reserved namespace for that Node Attestor starting in SPIRE 1.4. if agentID.Path() == idutil.ServerIDPath { - return api.MakeErr(log, codes.Internal, "agent ID cannot collide with the server ID", nil) + return commonapi.MakeErr(log, codes.Internal, "agent ID cannot collide with the server ID", nil) } if err := api.VerifyTrustDomainAgentIDForNodeAttestor(s.td, agentID, params.Data.Type); err != nil { log.WithError(err).Warn("The node attestor produced an invalid agent ID; future releases will enforce that agent IDs are within the reserved agent namesepace for the node attestor") @@ -359,11 +360,11 @@ func (s *Service) AttestAgent(stream agentv1.Agent_AttestAgentServer) error { // fetch the agent/node to check if it was already attested or banned attestedNode, err := s.ds.FetchAttestedNode(ctx, agentID.String()) if err != nil { - return api.MakeErr(log, codes.Internal, "failed to fetch agent", err) + return commonapi.MakeErr(log, codes.Internal, "failed to fetch agent", err) } if attestedNode != nil && nodeutil.IsAgentBanned(attestedNode) { - return api.MakeErr(log, codes.PermissionDenied, "failed to attest: agent is banned", nil) + return commonapi.MakeErr(log, codes.PermissionDenied, "failed to attest: agent is banned", nil) } // parse and sign CSR @@ -375,7 +376,7 @@ func (s *Service) AttestAgent(stream agentv1.Agent_AttestAgentServer) error { // dedupe and store node selectors err = s.ds.SetNodeSelectors(ctx, agentID.String(), selector.Dedupe(attestResult.Selectors)) if err != nil { - return api.MakeErr(log, codes.Internal, "failed to update selectors", err) + return commonapi.MakeErr(log, codes.Internal, "failed to update selectors", err) } // create or update attested entry @@ -388,7 +389,7 @@ func (s *Service) AttestAgent(stream agentv1.Agent_AttestAgentServer) error { CanReattest: attestResult.CanReattest, } if _, err := s.ds.CreateAttestedNode(ctx, node); err != nil { - return api.MakeErr(log, codes.Internal, "failed to create attested agent", err) + return commonapi.MakeErr(log, codes.Internal, "failed to create attested agent", err) } } else { node := &common.AttestedNode{ @@ -398,7 +399,7 @@ func (s *Service) AttestAgent(stream agentv1.Agent_AttestAgentServer) error { CanReattest: attestResult.CanReattest, } if _, err := s.ds.UpdateAttestedNode(ctx, node, api.UpdateAttestedNodeCertificateMask); err != nil { - return api.MakeErr(log, codes.Internal, "failed to update attested agent", err) + return commonapi.MakeErr(log, codes.Internal, "failed to update attested agent", err) } } @@ -411,7 +412,7 @@ func (s *Service) AttestAgent(stream agentv1.Agent_AttestAgentServer) error { log.Info("Agent attestation request completed") if err := stream.Send(response); err != nil { - return api.MakeErr(log, codes.Internal, "failed to send response over stream", err) + return commonapi.MakeErr(log, codes.Internal, "failed to send response over stream", err) } rpccontext.AuditRPC(ctx) @@ -426,21 +427,21 @@ func (s *Service) RenewAgent(ctx context.Context, req *agentv1.RenewAgentRequest } if err := rpccontext.RateLimit(ctx, 1); err != nil { - return nil, api.MakeErr(log, status.Code(err), "rejecting request due to renew agent rate limiting", err) + return nil, commonapi.MakeErr(log, status.Code(err), "rejecting request due to renew agent rate limiting", err) } callerID, ok := rpccontext.CallerID(ctx) if !ok { - return nil, api.MakeErr(log, codes.Internal, "caller ID missing from request context", nil) + return nil, commonapi.MakeErr(log, codes.Internal, "caller ID missing from request context", nil) } attestedNode, err := s.ds.FetchAttestedNode(ctx, callerID.String()) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to fetch agent", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to fetch agent", err) } if attestedNode == nil { - return nil, api.MakeErr(log, codes.NotFound, "agent not found", err) + return nil, commonapi.MakeErr(log, codes.NotFound, "agent not found", err) } // Agent attempted to renew when it should've been reattesting @@ -451,10 +452,10 @@ func (s *Service) RenewAgent(ctx context.Context, req *agentv1.RenewAgentRequest log.Info("Renewing agent SVID") if req.Params == nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "params cannot be nil", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "params cannot be nil", nil) } if len(req.Params.Csr) == 0 { - return nil, api.MakeErr(log, codes.InvalidArgument, "missing CSR", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "missing CSR", nil) } agentSVID, err := s.signSvid(ctx, callerID, req.Params.Csr, log) @@ -491,12 +492,12 @@ func (s *Service) PostStatus(ctx context.Context, req *agentv1.PostStatusRequest log := rpccontext.Logger(ctx) if err := rpccontext.RateLimit(ctx, 1); err != nil { - return nil, api.MakeErr(log, status.Code(err), "rejecting request due to post status rate limiting", err) + return nil, commonapi.MakeErr(log, status.Code(err), "rejecting request due to post status rate limiting", err) } callerID, ok := rpccontext.CallerID(ctx) if !ok { - return nil, api.MakeErr(log, codes.Internal, "caller ID missing from request context", nil) + return nil, commonapi.MakeErr(log, codes.Internal, "caller ID missing from request context", nil) } agentVersion := req.GetAgentVersion() @@ -508,7 +509,7 @@ func (s *Service) PostStatus(ctx context.Context, req *agentv1.PostStatusRequest if agentVersion != "" { if len(agentVersion) > 255 { - return nil, api.MakeErr(log, codes.InvalidArgument, "agent version is too long (max 255 characters)", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "agent version is too long (max 255 characters)", nil) } rpccontext.AddRPCAuditFields(ctx, logrus.Fields{ @@ -546,7 +547,7 @@ func (s *Service) CreateJoinToken(ctx context.Context, req *agentv1.CreateJoinTo rpccontext.AddRPCAuditFields(ctx, parseRequest()) if req.Ttl < 1 { - return nil, api.MakeErr(log, codes.InvalidArgument, "ttl is required, you must provide one", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "ttl is required, you must provide one", nil) } // If provided, check that the AgentID is valid BEFORE creating the join token so we can fail early @@ -555,7 +556,7 @@ func (s *Service) CreateJoinToken(ctx context.Context, req *agentv1.CreateJoinTo if req.AgentId != nil { agentID, err = api.TrustDomainWorkloadIDFromProto(ctx, s.td, req.AgentId) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "invalid agent ID", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "invalid agent ID", err) } rpccontext.AddRPCAuditFields(ctx, logrus.Fields{telemetry.SPIFFEID: agentID.String()}) log.WithField(telemetry.SPIFFEID, agentID.String()) @@ -565,7 +566,7 @@ func (s *Service) CreateJoinToken(ctx context.Context, req *agentv1.CreateJoinTo if req.Token == "" { u, err := uuid.NewV4() if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to generate token UUID", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to generate token UUID", err) } req.Token = u.String() } @@ -577,13 +578,13 @@ func (s *Service) CreateJoinToken(ctx context.Context, req *agentv1.CreateJoinTo Expiry: expiry, }) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to create token", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to create token", err) } if req.AgentId != nil { err := s.createJoinTokenRegistrationEntry(ctx, req.Token, agentID.String()) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to create join token registration entry", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to create join token registration entry", err) } } rpccontext.AuditRPC(ctx) @@ -596,6 +597,10 @@ func (s *Service) createJoinTokenRegistrationEntry(ctx context.Context, token st if err != nil { return fmt.Errorf("failed to create join token ID: %w", err) } + // This auto-alias entry shape (parent_id == the single "spiffe_id" selector's + // value) is what deleteAttestedNodeAndSelectors in the SQL datastore matches to + // cascade-delete the entry when the join-token node is removed. Changing the shape + // here requires updating that matcher (and TestCascadeDeleteJoinTokenAliasEntry). entry := &common.RegistrationEntry{ ParentId: parentID.String(), SpiffeId: agentID, @@ -613,16 +618,16 @@ func (s *Service) updateAttestedNode(ctx context.Context, node *common.AttestedN case codes.OK: return nil case codes.NotFound: - return api.MakeErr(log, codes.NotFound, "agent not found", err) + return commonapi.MakeErr(log, codes.NotFound, "agent not found", err) default: - return api.MakeErr(log, codes.Internal, "failed to update agent", err) + return commonapi.MakeErr(log, codes.Internal, "failed to update agent", err) } } func (s *Service) signSvid(ctx context.Context, agentID spiffeid.ID, csr []byte, log logrus.FieldLogger) ([]*x509.Certificate, error) { parsedCsr, err := x509.ParseCertificateRequest(csr) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "failed to parse CSR", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "failed to parse CSR", err) } // Sign a new X509 SVID @@ -631,7 +636,7 @@ func (s *Service) signSvid(ctx context.Context, agentID spiffeid.ID, csr []byte, PublicKey: parsedCsr.PublicKey, }) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to sign X509 SVID", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to sign X509 SVID", err) } return x509Svid, nil @@ -652,22 +657,22 @@ func (s *Service) attestJoinToken(ctx context.Context, token string) (*nodeattes joinToken, err := s.ds.FetchJoinToken(ctx, token) switch { case err != nil: - return nil, api.MakeErr(log, codes.Internal, "failed to fetch join token", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to fetch join token", err) case joinToken == nil: - return nil, api.MakeErr(log, codes.InvalidArgument, "failed to attest: join token does not exist or has already been used", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "failed to attest: join token does not exist or has already been used", nil) } err = s.ds.DeleteJoinToken(ctx, token) switch { case err != nil: - return nil, api.MakeErr(log, codes.Internal, "failed to delete join token", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to delete join token", err) case joinToken.Expiry.Before(s.clk.Now()): - return nil, api.MakeErr(log, codes.InvalidArgument, "join token expired", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "join token expired", nil) } agentID, err := joinTokenID(s.td, token) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to create join token ID", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to create join token ID", err) } return &nodeattestor.AttestResult{ @@ -681,7 +686,7 @@ func (s *Service) attestChallengeResponse(ctx context.Context, agentStream agent nodeAttestor, ok := s.cat.GetNodeAttestorNamed(attestorType) if !ok { - return nil, api.MakeErr(log, codes.FailedPrecondition, "error getting node attestor", fmt.Errorf("could not find node attestor type %q", attestorType)) + return nil, commonapi.MakeErr(log, codes.FailedPrecondition, "error getting node attestor", fmt.Errorf("could not find node attestor type %q", attestorType)) } result, err := nodeAttestor.Attest(ctx, params.Data.Payload, func(ctx context.Context, challenge []byte) ([]byte, error) { @@ -691,19 +696,19 @@ func (s *Service) attestChallengeResponse(ctx context.Context, agentStream agent }, } if err := agentStream.Send(resp); err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to send challenge to agent", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to send challenge to agent", err) } req, err := agentStream.Recv() if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to receive challenge from agent", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to receive challenge from agent", err) } return req.GetChallengeResponse(), nil }) if err != nil { st := status.Convert(err) - return nil, api.MakeErr(log, st.Code(), st.Message(), nil) + return nil, commonapi.MakeErr(log, st.Code(), st.Message(), nil) } return result, nil } diff --git a/pkg/server/api/agent/v1/service_test.go b/pkg/server/api/agent/v1/service_test.go index 03f0fff7c6..86a8d2a44c 100644 --- a/pkg/server/api/agent/v1/service_test.go +++ b/pkg/server/api/agent/v1/service_test.go @@ -2357,6 +2357,66 @@ func TestCreateJoinTokenWithAgentId(t *testing.T) { require.Equal(t, "spiffe://example.org/spire/agent/join_token/"+token.Value, listEntries.Entries[0].Selectors[0].Value) } +// TestCascadeDeleteJoinTokenAliasEntry exercises the real create→delete path: a +// CreateJoinToken with an AgentId writes the auto-alias entry via +// createJoinTokenRegistrationEntry, and DeleteAgent removes the join-token node, which +// must cascade-delete that alias entry through deleteAttestedNodeAndSelectors in the +// SQL datastore. If either side's notion of the alias shape drifts, this fails. +// fakedatastore is backed by the real sqlstore, so the cascade actually runs. +func TestCascadeDeleteJoinTokenAliasEntry(t *testing.T) { + test := setupServiceTest(t, 0, false) + defer test.Cleanup() + + token, err := test.client.CreateJoinToken(ctx, &agentv1.CreateJoinTokenRequest{ + Ttl: 1000, + AgentId: &types.SPIFFEID{TrustDomain: "example.org", Path: "/valid"}, + }) + require.NoError(t, err) + + nodeID := "spiffe://example.org/spire/agent/join_token/" + token.Value + + listEntries, err := test.ds.ListRegistrationEntries(ctx, &datastore.ListRegistrationEntriesRequest{}) + require.NoError(t, err) + require.Len(t, listEntries.Entries, 1) + aliasEntryID := listEntries.Entries[0].EntryId + require.Equal(t, nodeID, listEntries.Entries[0].ParentId) + + // Attest the node as a join-token node so it carries the SPIFFE ID and DataType + // the cascade keys on. + _, err = test.ds.CreateAttestedNode(ctx, &common.AttestedNode{ + SpiffeId: nodeID, + AttestationDataType: "join_token", + CertSerialNumber: "badcafe", + CertNotAfter: time.Now().Add(time.Hour).Unix(), + }) + require.NoError(t, err) + + beforeEvents, err := test.ds.ListRegistrationEntryEvents(ctx, &datastore.ListRegistrationEntryEventsRequest{}) + require.NoError(t, err) + var lastEventID uint + if n := len(beforeEvents.Events); n > 0 { + lastEventID = beforeEvents.Events[n-1].EventID + } + + _, err = test.client.DeleteAgent(ctx, &agentv1.DeleteAgentRequest{ + Id: &types.SPIFFEID{TrustDomain: "example.org", Path: "/spire/agent/join_token/" + token.Value}, + }) + require.NoError(t, err) + + // The alias entry is cascade-deleted... + deleted, err := test.ds.FetchRegistrationEntry(ctx, aliasEntryID) + require.NoError(t, err) + require.Nil(t, deleted) + + // ...and a registration entry event is emitted for it. + events, err := test.ds.ListRegistrationEntryEvents(ctx, &datastore.ListRegistrationEntryEventsRequest{ + GreaterThanEventID: lastEventID, + }) + require.NoError(t, err) + require.Len(t, events.Events, 1) + require.Equal(t, aliasEntryID, events.Events[0].EntryID) +} + func TestAttestAgent(t *testing.T) { testCsr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{}, testKey) require.NoError(t, err) diff --git a/pkg/server/api/bundle/v1/service.go b/pkg/server/api/bundle/v1/service.go index 1c4a1ba9ec..985546b0a7 100644 --- a/pkg/server/api/bundle/v1/service.go +++ b/pkg/server/api/bundle/v1/service.go @@ -9,6 +9,7 @@ import ( "github.com/spiffe/go-spiffe/v2/spiffeid" bundlev1 "github.com/spiffe/spire-api-sdk/proto/spire/api/server/bundle/v1" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + commonapi "github.com/spiffe/spire/pkg/common/api" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/server/api" "github.com/spiffe/spire/pkg/server/api/rpccontext" @@ -68,7 +69,7 @@ func (s *Service) CountBundles(ctx context.Context, _ *bundlev1.CountBundlesRequ count, err := s.ds.CountBundles(ctx) if err != nil { log := rpccontext.Logger(ctx) - return nil, api.MakeErr(log, codes.Internal, "failed to count bundles", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to count bundles", err) } rpccontext.AuditRPC(ctx) @@ -82,16 +83,16 @@ func (s *Service) GetBundle(ctx context.Context, req *bundlev1.GetBundleRequest) commonBundle, err := s.ds.FetchBundle(dscache.WithCache(ctx), s.td.IDString()) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to fetch bundle", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to fetch bundle", err) } if commonBundle == nil { - return nil, api.MakeErr(log, codes.NotFound, "bundle not found", nil) + return nil, commonapi.MakeErr(log, codes.NotFound, "bundle not found", nil) } bundle, err := api.BundleToProto(commonBundle) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to convert bundle", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to convert bundle", err) } applyBundleMask(bundle, req.OutputMask) @@ -114,19 +115,19 @@ func (s *Service) AppendBundle(ctx context.Context, req *bundlev1.AppendBundleRe log := rpccontext.Logger(ctx) if len(req.JwtAuthorities) == 0 && len(req.X509Authorities) == 0 { - return nil, api.MakeErr(log, codes.InvalidArgument, "no authorities to append", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "no authorities to append", nil) } log = log.WithField(telemetry.TrustDomainID, s.td.Name()) jwtAuth, err := api.ParseJWTAuthorities(req.JwtAuthorities) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "failed to convert JWT authority", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "failed to convert JWT authority", err) } x509Auth, err := api.ParseX509Authorities(req.X509Authorities) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "failed to convert X.509 authority", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "failed to convert X.509 authority", err) } dsBundle, err := s.ds.AppendBundle(ctx, &common.Bundle{ @@ -135,12 +136,12 @@ func (s *Service) AppendBundle(ctx context.Context, req *bundlev1.AppendBundleRe RootCas: x509Auth, }) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to append bundle", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to append bundle", err) } bundle, err := api.BundleToProto(dsBundle) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to convert bundle", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to convert bundle", err) } applyBundleMask(bundle, req.OutputMask) @@ -163,21 +164,21 @@ func (s *Service) PublishJWTAuthority(ctx context.Context, req *bundlev1.Publish log := rpccontext.Logger(ctx) if err := rpccontext.RateLimit(ctx, 1); err != nil { - return nil, api.MakeErr(log, status.Code(err), "rejecting request due to key publishing rate limiting", err) + return nil, commonapi.MakeErr(log, status.Code(err), "rejecting request due to key publishing rate limiting", err) } if req.JwtAuthority == nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "missing JWT authority", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "missing JWT authority", nil) } keys, err := api.ParseJWTAuthorities([]*types.JWTKey{req.JwtAuthority}) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "invalid JWT authority", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "invalid JWT authority", err) } resp, err := s.up.PublishJWTKey(ctx, keys[0]) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to publish JWT key", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to publish JWT key", err) } rpccontext.AuditRPC(ctx) @@ -189,7 +190,7 @@ func (s *Service) PublishJWTAuthority(ctx context.Context, req *bundlev1.Publish // PublishWITAuthority published the WIT key on the server. func (s *Service) PublishWITAuthority(ctx context.Context, req *bundlev1.PublishWITAuthorityRequest) (*bundlev1.PublishWITAuthorityResponse, error) { log := rpccontext.Logger(ctx) - return nil, api.MakeErr(log, codes.Unimplemented, "WIT-SVID functionality is not yet implemented", nil) + return nil, commonapi.MakeErr(log, codes.Unimplemented, "WIT-SVID functionality is not yet implemented", nil) } // ListFederatedBundles returns an optionally paginated list of federated bundles. @@ -208,7 +209,7 @@ func (s *Service) ListFederatedBundles(ctx context.Context, req *bundlev1.ListFe dsResp, err := s.ds.ListBundles(ctx, listReq) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to list bundles", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to list bundles", err) } resp := &bundlev1.ListFederatedBundlesResponse{} @@ -221,7 +222,7 @@ func (s *Service) ListFederatedBundles(ctx context.Context, req *bundlev1.ListFe log = log.WithField(telemetry.TrustDomainID, commonBundle.TrustDomainId) td, err := spiffeid.TrustDomainFromString(commonBundle.TrustDomainId) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "bundle has an invalid trust domain ID", err) + return nil, commonapi.MakeErr(log, codes.Internal, "bundle has an invalid trust domain ID", err) } // Filter server bundle @@ -231,7 +232,7 @@ func (s *Service) ListFederatedBundles(ctx context.Context, req *bundlev1.ListFe b, err := api.BundleToProto(commonBundle) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to convert bundle", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to convert bundle", err) } applyBundleMask(b, req.OutputMask) resp.Bundles = append(resp.Bundles, b) @@ -248,25 +249,25 @@ func (s *Service) GetFederatedBundle(ctx context.Context, req *bundlev1.GetFeder td, err := spiffeid.TrustDomainFromString(req.TrustDomain) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "trust domain argument is not valid", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "trust domain argument is not valid", err) } if s.td.Compare(td) == 0 { - return nil, api.MakeErr(log, codes.InvalidArgument, "getting a federated bundle for the server's own trust domain is not allowed", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "getting a federated bundle for the server's own trust domain is not allowed", nil) } commonBundle, err := s.ds.FetchBundle(ctx, td.IDString()) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to fetch bundle", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to fetch bundle", err) } if commonBundle == nil { - return nil, api.MakeErr(log, codes.NotFound, "bundle not found", nil) + return nil, commonapi.MakeErr(log, codes.NotFound, "bundle not found", nil) } bundle, err := api.BundleToProto(commonBundle) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to convert bundle", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to convert bundle", err) } applyBundleMask(bundle, req.OutputMask) @@ -298,20 +299,20 @@ func (s *Service) createFederatedBundle(ctx context.Context, b *types.Bundle, ou td, err := spiffeid.TrustDomainFromString(b.TrustDomain) if err != nil { return &bundlev1.BatchCreateFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "trust domain argument is not valid", err), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "trust domain argument is not valid", err), } } if s.td.Compare(td) == 0 { return &bundlev1.BatchCreateFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "creating a federated bundle for the server's own trust domain is not allowed", nil), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "creating a federated bundle for the server's own trust domain is not allowed", nil), } } commonBundle, err := api.ProtoToBundle(b) if err != nil { return &bundlev1.BatchCreateFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "failed to convert bundle", err), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "failed to convert bundle", err), } } @@ -320,18 +321,18 @@ func (s *Service) createFederatedBundle(ctx context.Context, b *types.Bundle, ou case codes.OK: case codes.AlreadyExists: return &bundlev1.BatchCreateFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.AlreadyExists, "bundle already exists", nil), + Status: commonapi.MakeStatus(log, codes.AlreadyExists, "bundle already exists", nil), } default: return &bundlev1.BatchCreateFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.Internal, "unable to create bundle", err), + Status: commonapi.MakeStatus(log, codes.Internal, "unable to create bundle", err), } } protoBundle, err := api.BundleToProto(cb) if err != nil { return &bundlev1.BatchCreateFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.Internal, "failed to convert bundle", err), + Status: commonapi.MakeStatus(log, codes.Internal, "failed to convert bundle", err), } } @@ -339,7 +340,7 @@ func (s *Service) createFederatedBundle(ctx context.Context, b *types.Bundle, ou log.Debug("Federated bundle created") return &bundlev1.BatchCreateFederatedBundleResponse_Result{ - Status: api.OK(), + Status: commonapi.OK(), Bundle: protoBundle, } } @@ -350,41 +351,41 @@ func (s *Service) setFederatedBundle(ctx context.Context, b *types.Bundle, outpu td, err := spiffeid.TrustDomainFromString(b.TrustDomain) if err != nil { return &bundlev1.BatchSetFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "trust domain argument is not valid", err), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "trust domain argument is not valid", err), } } if s.td.Compare(td) == 0 { return &bundlev1.BatchSetFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "setting a federated bundle for the server's own trust domain is not allowed", nil), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "setting a federated bundle for the server's own trust domain is not allowed", nil), } } commonBundle, err := api.ProtoToBundle(b) if err != nil { return &bundlev1.BatchSetFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "failed to convert bundle", err), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "failed to convert bundle", err), } } dsBundle, err := s.ds.SetBundle(ctx, commonBundle) if err != nil { return &bundlev1.BatchSetFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.Internal, "failed to set bundle", err), + Status: commonapi.MakeStatus(log, codes.Internal, "failed to set bundle", err), } } protoBundle, err := api.BundleToProto(dsBundle) if err != nil { return &bundlev1.BatchSetFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.Internal, "failed to convert bundle", err), + Status: commonapi.MakeStatus(log, codes.Internal, "failed to convert bundle", err), } } applyBundleMask(protoBundle, outputMask) log.Info("Bundle set successfully") return &bundlev1.BatchSetFederatedBundleResponse_Result{ - Status: api.OK(), + Status: commonapi.OK(), Bundle: protoBundle, } } @@ -412,20 +413,20 @@ func (s *Service) updateFederatedBundle(ctx context.Context, b *types.Bundle, in td, err := spiffeid.TrustDomainFromString(b.TrustDomain) if err != nil { return &bundlev1.BatchUpdateFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "trust domain argument is not valid", err), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "trust domain argument is not valid", err), } } if s.td.Compare(td) == 0 { return &bundlev1.BatchUpdateFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "updating a federated bundle for the server's own trust domain is not allowed", nil), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "updating a federated bundle for the server's own trust domain is not allowed", nil), } } commonBundle, err := api.ProtoToBundle(b) if err != nil { return &bundlev1.BatchUpdateFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "failed to convert bundle", err), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "failed to convert bundle", err), } } dsBundle, err := s.ds.UpdateBundle(ctx, commonBundle, api.ProtoToBundleMask(inputMask)) @@ -434,18 +435,18 @@ func (s *Service) updateFederatedBundle(ctx context.Context, b *types.Bundle, in case codes.OK: case codes.NotFound: return &bundlev1.BatchUpdateFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.NotFound, "bundle not found", err), + Status: commonapi.MakeStatus(log, codes.NotFound, "bundle not found", err), } default: return &bundlev1.BatchUpdateFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.Internal, "failed to update bundle", err), + Status: commonapi.MakeStatus(log, codes.Internal, "failed to update bundle", err), } } protoBundle, err := api.BundleToProto(dsBundle) if err != nil { return &bundlev1.BatchUpdateFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.Internal, "failed to convert bundle", err), + Status: commonapi.MakeStatus(log, codes.Internal, "failed to convert bundle", err), } } @@ -453,7 +454,7 @@ func (s *Service) updateFederatedBundle(ctx context.Context, b *types.Bundle, in log.Debug("Federated bundle updated") return &bundlev1.BatchUpdateFederatedBundleResponse_Result{ - Status: api.OK(), + Status: commonapi.OK(), Bundle: protoBundle, } } @@ -480,7 +481,7 @@ func (s *Service) BatchDeleteFederatedBundle(ctx context.Context, req *bundlev1. log := rpccontext.Logger(ctx) mode, err := parseDeleteMode(req.Mode) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "failed to parse deletion mode", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "failed to parse deletion mode", err) } log = log.WithField(telemetry.DeleteFederatedBundleMode, mode.String()) @@ -508,7 +509,7 @@ func (s *Service) deleteFederatedBundle(ctx context.Context, log logrus.FieldLog td, err := spiffeid.TrustDomainFromString(trustDomain) if err != nil { return &bundlev1.BatchDeleteFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "trust domain argument is not valid", err), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "trust domain argument is not valid", err), TrustDomain: trustDomain, } } @@ -516,7 +517,7 @@ func (s *Service) deleteFederatedBundle(ctx context.Context, log logrus.FieldLog if s.td.Compare(td) == 0 { return &bundlev1.BatchDeleteFederatedBundleResponse_Result{ TrustDomain: trustDomain, - Status: api.MakeStatus(log, codes.InvalidArgument, "removing the bundle for the server trust domain is not allowed", nil), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "removing the bundle for the server trust domain is not allowed", nil), } } @@ -526,18 +527,18 @@ func (s *Service) deleteFederatedBundle(ctx context.Context, log logrus.FieldLog switch code { case codes.OK: return &bundlev1.BatchDeleteFederatedBundleResponse_Result{ - Status: api.OK(), + Status: commonapi.OK(), TrustDomain: trustDomain, } case codes.NotFound: return &bundlev1.BatchDeleteFederatedBundleResponse_Result{ - Status: api.MakeStatus(log, codes.NotFound, "bundle not found", err), + Status: commonapi.MakeStatus(log, codes.NotFound, "bundle not found", err), TrustDomain: trustDomain, } default: return &bundlev1.BatchDeleteFederatedBundleResponse_Result{ TrustDomain: trustDomain, - Status: api.MakeStatus(log, code, "failed to delete federated bundle", err), + Status: commonapi.MakeStatus(log, code, "failed to delete federated bundle", err), } } } diff --git a/pkg/server/api/bundle/v1/service_test.go b/pkg/server/api/bundle/v1/service_test.go index 3a89d35a4e..8fb5ccf2c9 100644 --- a/pkg/server/api/bundle/v1/service_test.go +++ b/pkg/server/api/bundle/v1/service_test.go @@ -18,6 +18,7 @@ import ( "github.com/spiffe/go-spiffe/v2/spiffeid" bundlev1 "github.com/spiffe/spire-api-sdk/proto/spire/api/server/bundle/v1" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + commonapi "github.com/spiffe/spire/pkg/common/api" "github.com/spiffe/spire/pkg/common/jwtutil" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/server/api" @@ -1762,7 +1763,7 @@ func TestBatchCreateFederatedBundle(t *testing.T) { }, expectedResults: []*bundlev1.BatchCreateFederatedBundleResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), Bundle: &types.Bundle{ TrustDomain: "another-example.org", RefreshHint: 60, @@ -1801,7 +1802,7 @@ func TestBatchCreateFederatedBundle(t *testing.T) { outputMask: &types.BundleMask{}, expectedResults: []*bundlev1.BatchCreateFederatedBundleResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), Bundle: &types.Bundle{TrustDomain: federatedTrustDomain.Name()}, }, }, @@ -1835,7 +1836,7 @@ func TestBatchCreateFederatedBundle(t *testing.T) { bundlesToCreate: []*types.Bundle{makeValidBundle(t, federatedTrustDomain)}, expectedResults: []*bundlev1.BatchCreateFederatedBundleResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), Bundle: makeValidBundle(t, federatedTrustDomain), }, }, @@ -1878,7 +1879,7 @@ func TestBatchCreateFederatedBundle(t *testing.T) { }(), }, expectedResults: []*bundlev1.BatchCreateFederatedBundleResponse_Result{ - {Status: api.CreateStatus(codes.InvalidArgument, `trust domain argument is not valid: trust domain characters are limited to lowercase letters, numbers, dots, dashes, and underscores`)}, + {Status: commonapi.CreateStatus(codes.InvalidArgument, `trust domain argument is not valid: trust domain characters are limited to lowercase letters, numbers, dots, dashes, and underscores`)}, }, expectedLogMsgs: []spiretest.LogEntry{ { @@ -1918,7 +1919,7 @@ func TestBatchCreateFederatedBundle(t *testing.T) { }(), }, expectedResults: []*bundlev1.BatchCreateFederatedBundleResponse_Result{ - {Status: api.CreateStatus(codes.InvalidArgument, `creating a federated bundle for the server's own trust domain is not allowed`)}, + {Status: commonapi.CreateStatus(codes.InvalidArgument, `creating a federated bundle for the server's own trust domain is not allowed`)}, }, expectedLogMsgs: []spiretest.LogEntry{ { @@ -1952,11 +1953,11 @@ func TestBatchCreateFederatedBundle(t *testing.T) { bundlesToCreate: []*types.Bundle{makeValidBundle(t, federatedTrustDomain), makeValidBundle(t, federatedTrustDomain)}, expectedResults: []*bundlev1.BatchCreateFederatedBundleResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), Bundle: makeValidBundle(t, federatedTrustDomain), }, { - Status: api.CreateStatus(codes.AlreadyExists, "bundle already exists"), + Status: commonapi.CreateStatus(codes.AlreadyExists, "bundle already exists"), }, }, expectedLogMsgs: []spiretest.LogEntry{ @@ -2013,7 +2014,7 @@ func TestBatchCreateFederatedBundle(t *testing.T) { bundlesToCreate: []*types.Bundle{makeValidBundle(t, federatedTrustDomain)}, dsError: errors.New("datastore error"), expectedResults: []*bundlev1.BatchCreateFederatedBundleResponse_Result{ - {Status: api.CreateStatus(codes.Internal, `unable to create bundle: datastore error`)}, + {Status: commonapi.CreateStatus(codes.Internal, `unable to create bundle: datastore error`)}, }, expectedLogMsgs: []spiretest.LogEntry{ { @@ -2056,7 +2057,7 @@ func TestBatchCreateFederatedBundle(t *testing.T) { }, }, expectedResults: []*bundlev1.BatchCreateFederatedBundleResponse_Result{ - {Status: api.CreateStatusf(codes.InvalidArgument, `failed to convert bundle: unable to parse X.509 authority: %v`, expectedX509Err)}, + {Status: commonapi.CreateStatusf(codes.InvalidArgument, `failed to convert bundle: unable to parse X.509 authority: %v`, expectedX509Err)}, }, expectedLogMsgs: []spiretest.LogEntry{ { @@ -2132,7 +2133,7 @@ func TestBatchUpdateFederatedBundle(t *testing.T) { }, expectedResults: []*bundlev1.BatchCreateFederatedBundleResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), Bundle: makeValidBundle(t, federatedTrustDomain), }, }, @@ -2173,7 +2174,7 @@ func TestBatchUpdateFederatedBundle(t *testing.T) { }, expectedResults: []*bundlev1.BatchCreateFederatedBundleResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), Bundle: makeValidBundle(t, federatedTrustDomain), }, }, @@ -2211,7 +2212,7 @@ func TestBatchUpdateFederatedBundle(t *testing.T) { }, expectedResults: []*bundlev1.BatchCreateFederatedBundleResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), Bundle: &types.Bundle{ TrustDomain: federatedTrustDomain.Name(), RefreshHint: makeValidBundle(t, federatedTrustDomain).RefreshHint, @@ -2257,7 +2258,7 @@ func TestBatchUpdateFederatedBundle(t *testing.T) { }(), }, expectedResults: []*bundlev1.BatchCreateFederatedBundleResponse_Result{ - {Status: api.CreateStatus(codes.InvalidArgument, `trust domain argument is not valid: trust domain characters are limited to lowercase letters, numbers, dots, dashes, and underscores`)}, + {Status: commonapi.CreateStatus(codes.InvalidArgument, `trust domain argument is not valid: trust domain characters are limited to lowercase letters, numbers, dots, dashes, and underscores`)}, }, expectedLogMsgs: []spiretest.LogEntry{ { @@ -2297,7 +2298,7 @@ func TestBatchUpdateFederatedBundle(t *testing.T) { }(), }, expectedResults: []*bundlev1.BatchCreateFederatedBundleResponse_Result{ - {Status: api.CreateStatus(codes.InvalidArgument, `updating a federated bundle for the server's own trust domain is not allowed`)}, + {Status: commonapi.CreateStatus(codes.InvalidArgument, `updating a federated bundle for the server's own trust domain is not allowed`)}, }, expectedLogMsgs: []spiretest.LogEntry{ { @@ -2331,7 +2332,7 @@ func TestBatchUpdateFederatedBundle(t *testing.T) { bundlesToUpdate: []*types.Bundle{makeValidBundle(t, federatedTrustDomain)}, expectedResults: []*bundlev1.BatchCreateFederatedBundleResponse_Result{ { - Status: api.CreateStatus(codes.NotFound, "bundle not found"), + Status: commonapi.CreateStatus(codes.NotFound, "bundle not found"), }, }, expectedLogMsgs: []spiretest.LogEntry{ @@ -2366,7 +2367,7 @@ func TestBatchUpdateFederatedBundle(t *testing.T) { bundlesToUpdate: []*types.Bundle{makeValidBundle(t, federatedTrustDomain)}, dsError: errors.New("datastore error"), expectedResults: []*bundlev1.BatchCreateFederatedBundleResponse_Result{ - {Status: api.CreateStatus(codes.Internal, `failed to update bundle: datastore error`)}, + {Status: commonapi.CreateStatus(codes.Internal, `failed to update bundle: datastore error`)}, }, expectedLogMsgs: []spiretest.LogEntry{ { @@ -2409,7 +2410,7 @@ func TestBatchUpdateFederatedBundle(t *testing.T) { }, }, expectedResults: []*bundlev1.BatchCreateFederatedBundleResponse_Result{ - {Status: api.CreateStatus(codes.InvalidArgument, fmt.Sprintf("failed to convert bundle: unable to parse X.509 authority: %v", expectedX509Err))}, + {Status: commonapi.CreateStatus(codes.InvalidArgument, fmt.Sprintf("failed to convert bundle: unable to parse X.509 authority: %v", expectedX509Err))}, }, expectedLogMsgs: []spiretest.LogEntry{ { @@ -2442,10 +2443,10 @@ func TestBatchUpdateFederatedBundle(t *testing.T) { bundlesToUpdate: []*types.Bundle{makeValidBundle(t, spiffeid.RequireTrustDomainFromString("non-existent-td")), makeValidBundle(t, federatedTrustDomain)}, expectedResults: []*bundlev1.BatchCreateFederatedBundleResponse_Result{ { - Status: api.CreateStatus(codes.NotFound, "bundle not found"), + Status: commonapi.CreateStatus(codes.NotFound, "bundle not found"), }, { - Status: api.OK(), + Status: commonapi.OK(), Bundle: makeValidBundle(t, federatedTrustDomain), }, }, @@ -2568,7 +2569,7 @@ func TestBatchSetFederatedBundle(t *testing.T) { }, expectedResults: []*bundlev1.BatchSetFederatedBundleResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), Bundle: &types.Bundle{ TrustDomain: "another-example.org", RefreshHint: 60, @@ -2606,7 +2607,7 @@ func TestBatchSetFederatedBundle(t *testing.T) { outputMask: &types.BundleMask{}, expectedResults: []*bundlev1.BatchSetFederatedBundleResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), Bundle: &types.Bundle{TrustDomain: federatedTrustDomain.Name()}, }, }, @@ -2640,7 +2641,7 @@ func TestBatchSetFederatedBundle(t *testing.T) { bundlesToSet: []*types.Bundle{makeValidBundle(t, federatedTrustDomain)}, expectedResults: []*bundlev1.BatchSetFederatedBundleResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), Bundle: makeValidBundle(t, federatedTrustDomain), }, }, @@ -2678,11 +2679,11 @@ func TestBatchSetFederatedBundle(t *testing.T) { bundlesToSet: []*types.Bundle{makeValidBundle(t, federatedTrustDomain), updatedBundle}, expectedResults: []*bundlev1.BatchSetFederatedBundleResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), Bundle: makeValidBundle(t, federatedTrustDomain), }, { - Status: api.OK(), + Status: commonapi.OK(), Bundle: updatedBundle, }, }, @@ -2743,7 +2744,7 @@ func TestBatchSetFederatedBundle(t *testing.T) { }(), }, expectedResults: []*bundlev1.BatchSetFederatedBundleResponse_Result{ - {Status: api.CreateStatus(codes.InvalidArgument, `trust domain argument is not valid: trust domain characters are limited to lowercase letters, numbers, dots, dashes, and underscores`)}, + {Status: commonapi.CreateStatus(codes.InvalidArgument, `trust domain argument is not valid: trust domain characters are limited to lowercase letters, numbers, dots, dashes, and underscores`)}, }, expectedLogMsgs: []spiretest.LogEntry{ { @@ -2783,7 +2784,7 @@ func TestBatchSetFederatedBundle(t *testing.T) { }(), }, expectedResults: []*bundlev1.BatchSetFederatedBundleResponse_Result{ - {Status: api.CreateStatus(codes.InvalidArgument, `setting a federated bundle for the server's own trust domain is not allowed`)}, + {Status: commonapi.CreateStatus(codes.InvalidArgument, `setting a federated bundle for the server's own trust domain is not allowed`)}, }, expectedLogMsgs: []spiretest.LogEntry{ { @@ -2817,7 +2818,7 @@ func TestBatchSetFederatedBundle(t *testing.T) { bundlesToSet: []*types.Bundle{makeValidBundle(t, federatedTrustDomain)}, dsError: errors.New("datastore error"), expectedResults: []*bundlev1.BatchSetFederatedBundleResponse_Result{ - {Status: api.CreateStatus(codes.Internal, `failed to set bundle: datastore error`)}, + {Status: commonapi.CreateStatus(codes.Internal, `failed to set bundle: datastore error`)}, }, expectedLogMsgs: []spiretest.LogEntry{ { @@ -2860,7 +2861,7 @@ func TestBatchSetFederatedBundle(t *testing.T) { }, }, expectedResults: []*bundlev1.BatchSetFederatedBundleResponse_Result{ - {Status: api.CreateStatusf(codes.InvalidArgument, `failed to convert bundle: unable to parse X.509 authority: %v`, expectedX509Err)}, + {Status: commonapi.CreateStatusf(codes.InvalidArgument, `failed to convert bundle: unable to parse X.509 authority: %v`, expectedX509Err)}, }, expectedLogMsgs: []spiretest.LogEntry{ { diff --git a/pkg/server/api/debug/v1/service.go b/pkg/server/api/debug/v1/service.go index 216a7e2154..6d046812fa 100644 --- a/pkg/server/api/debug/v1/service.go +++ b/pkg/server/api/debug/v1/service.go @@ -12,7 +12,7 @@ import ( "github.com/spiffe/go-spiffe/v2/svid/x509svid" debugv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/server/debug/v1" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" - "github.com/spiffe/spire/pkg/server/api" + commonapi "github.com/spiffe/spire/pkg/common/api" "github.com/spiffe/spire/pkg/server/api/rpccontext" "github.com/spiffe/spire/pkg/server/datastore" "github.com/spiffe/spire/pkg/server/svid" @@ -80,16 +80,16 @@ func (s *Service) GetInfo(ctx context.Context, _ *debugv1.GetInfoRequest) (*debu if s.getInfoResp.ts.IsZero() || s.clock.Now().Sub(s.getInfoResp.ts) >= cacheExpiry { nodes, err := s.ds.CountAttestedNodes(ctx, &datastore.CountAttestedNodesRequest{}) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to count agents", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to count agents", err) } entries, err := s.ds.CountRegistrationEntries(ctx, &datastore.CountRegistrationEntriesRequest{}) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to count entries", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to count entries", err) } bundles, err := s.ds.CountBundles(ctx) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to count bundles", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to count bundles", err) } svidChain, err := s.getCertificateChain(ctx, log) @@ -117,11 +117,11 @@ func (s *Service) getCertificateChain(ctx context.Context, log logrus.FieldLogge // Extract trustdomains bundle and append federated bundles bundle, err := s.ds.FetchBundle(ctx, trustDomainID) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to fetch trust domain bundle", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to fetch trust domain bundle", err) } if bundle == nil { - return nil, api.MakeErr(log, codes.NotFound, "trust domain bundle not found", nil) + return nil, commonapi.MakeErr(log, codes.NotFound, "trust domain bundle not found", nil) } // Create bundle source using rootCAs @@ -129,7 +129,7 @@ func (s *Service) getCertificateChain(ctx context.Context, log logrus.FieldLogge for _, b := range bundle.RootCas { cert, err := x509.ParseCertificate(b.DerBytes) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to parse bundle", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to parse bundle", err) } rootCAs = append(rootCAs, cert) } @@ -138,7 +138,7 @@ func (s *Service) getCertificateChain(ctx context.Context, log logrus.FieldLogge // Verify certificate to extract SVID chain _, chains, err := x509svid.Verify(s.so.State().SVID, bundleSource) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed verification against bundle", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed verification against bundle", err) } // Create SVID chain for response diff --git a/pkg/server/api/entry/v1/service.go b/pkg/server/api/entry/v1/service.go index f63a9adc17..8caf37fd68 100644 --- a/pkg/server/api/entry/v1/service.go +++ b/pkg/server/api/entry/v1/service.go @@ -12,6 +12,7 @@ import ( "github.com/spiffe/go-spiffe/v2/spiffeid" entryv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/server/entry/v1" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + commonapi "github.com/spiffe/spire/pkg/common/api" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/server/api" "github.com/spiffe/spire/pkg/server/api/rpccontext" @@ -74,7 +75,7 @@ func (s *Service) CountEntries(ctx context.Context, req *entryv1.CountEntriesReq if req.Filter.ByParentId != nil { parentID, err := api.TrustDomainMemberIDFromProto(ctx, s.td, req.Filter.ByParentId) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "malformed parent ID filter", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "malformed parent ID filter", err) } countReq.ByParentID = parentID.String() } @@ -82,7 +83,7 @@ func (s *Service) CountEntries(ctx context.Context, req *entryv1.CountEntriesReq if req.Filter.BySpiffeId != nil { spiffeID, err := api.TrustDomainWorkloadIDFromProto(ctx, s.td, req.Filter.BySpiffeId) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "malformed SPIFFE ID filter", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "malformed SPIFFE ID filter", err) } countReq.BySpiffeID = spiffeID.String() } @@ -90,10 +91,10 @@ func (s *Service) CountEntries(ctx context.Context, req *entryv1.CountEntriesReq if req.Filter.BySelectors != nil { dsSelectors, err := api.SelectorsFromProto(req.Filter.BySelectors.Selectors) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "malformed selectors filter", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "malformed selectors filter", err) } if len(dsSelectors) == 0 { - return nil, api.MakeErr(log, codes.InvalidArgument, "malformed selectors filter", errors.New("empty selector set")) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "malformed selectors filter", errors.New("empty selector set")) } countReq.BySelectors = &datastore.BySelectors{ Match: datastore.MatchBehavior(req.Filter.BySelectors.Match), @@ -106,12 +107,12 @@ func (s *Service) CountEntries(ctx context.Context, req *entryv1.CountEntriesReq for _, tdStr := range req.Filter.ByFederatesWith.TrustDomains { td, err := spiffeid.TrustDomainFromString(tdStr) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "malformed federates with filter", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "malformed federates with filter", err) } trustDomains = append(trustDomains, td.IDString()) } if len(trustDomains) == 0 { - return nil, api.MakeErr(log, codes.InvalidArgument, "malformed federates with filter", errors.New("empty trust domain set")) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "malformed federates with filter", errors.New("empty trust domain set")) } countReq.ByFederatesWith = &datastore.ByFederatesWith{ Match: datastore.MatchBehavior(req.Filter.ByFederatesWith.Match), @@ -127,7 +128,7 @@ func (s *Service) CountEntries(ctx context.Context, req *entryv1.CountEntriesReq count, err := s.ds.CountRegistrationEntries(ctx, countReq) if err != nil { log := rpccontext.Logger(ctx) - return nil, api.MakeErr(log, codes.Internal, "failed to count entries", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to count entries", err) } rpccontext.AuditRPC(ctx) @@ -157,7 +158,7 @@ func (s *Service) ListEntries(ctx context.Context, req *entryv1.ListEntriesReque if req.Filter.ByParentId != nil { parentID, err := api.TrustDomainMemberIDFromProto(ctx, s.td, req.Filter.ByParentId) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "malformed parent ID filter", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "malformed parent ID filter", err) } listReq.ByParentID = parentID.String() } @@ -165,7 +166,7 @@ func (s *Service) ListEntries(ctx context.Context, req *entryv1.ListEntriesReque if req.Filter.BySpiffeId != nil { spiffeID, err := api.TrustDomainWorkloadIDFromProto(ctx, s.td, req.Filter.BySpiffeId) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "malformed SPIFFE ID filter", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "malformed SPIFFE ID filter", err) } listReq.BySpiffeID = spiffeID.String() } @@ -173,10 +174,10 @@ func (s *Service) ListEntries(ctx context.Context, req *entryv1.ListEntriesReque if req.Filter.BySelectors != nil { dsSelectors, err := api.SelectorsFromProto(req.Filter.BySelectors.Selectors) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "malformed selectors filter", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "malformed selectors filter", err) } if len(dsSelectors) == 0 { - return nil, api.MakeErr(log, codes.InvalidArgument, "malformed selectors filter", errors.New("empty selector set")) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "malformed selectors filter", errors.New("empty selector set")) } listReq.BySelectors = &datastore.BySelectors{ Match: datastore.MatchBehavior(req.Filter.BySelectors.Match), @@ -189,12 +190,12 @@ func (s *Service) ListEntries(ctx context.Context, req *entryv1.ListEntriesReque for _, tdStr := range req.Filter.ByFederatesWith.TrustDomains { td, err := spiffeid.TrustDomainFromString(tdStr) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "malformed federates with filter", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "malformed federates with filter", err) } trustDomains = append(trustDomains, td.IDString()) } if len(trustDomains) == 0 { - return nil, api.MakeErr(log, codes.InvalidArgument, "malformed federates with filter", errors.New("empty trust domain set")) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "malformed federates with filter", errors.New("empty trust domain set")) } listReq.ByFederatesWith = &datastore.ByFederatesWith{ Match: datastore.MatchBehavior(req.Filter.ByFederatesWith.Match), @@ -209,7 +210,7 @@ func (s *Service) ListEntries(ctx context.Context, req *entryv1.ListEntriesReque dsResp, err := s.ds.ListRegistrationEntries(ctx, listReq) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to list entries", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to list entries", err) } resp := &entryv1.ListEntriesResponse{} @@ -236,22 +237,22 @@ func (s *Service) GetEntry(ctx context.Context, req *entryv1.GetEntryRequest) (* log := rpccontext.Logger(ctx) if req.Id == "" { - return nil, api.MakeErr(log, codes.InvalidArgument, "missing ID", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "missing ID", nil) } rpccontext.AddRPCAuditFields(ctx, logrus.Fields{telemetry.RegistrationID: req.Id}) log = log.WithField(telemetry.RegistrationID, req.Id) registrationEntry, err := s.ds.FetchRegistrationEntry(ctx, req.Id) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to fetch entry", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to fetch entry", err) } if registrationEntry == nil { - return nil, api.MakeErr(log, codes.NotFound, "entry not found", nil) + return nil, commonapi.MakeErr(log, codes.NotFound, "entry not found", nil) } entry, err := api.RegistrationEntryToProto(registrationEntry) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to convert entry", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to convert entry", err) } applyMask(entry, req.OutputMask) rpccontext.AuditRPC(ctx) @@ -281,13 +282,13 @@ func (s *Service) createEntry(ctx context.Context, e *types.Entry, outputMask *t cEntry, err := api.ProtoToRegistrationEntry(ctx, s.td, e) if err != nil { return &entryv1.BatchCreateEntryResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "failed to convert entry", err), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "failed to convert entry", err), } } log = log.WithField(telemetry.SPIFFEID, cEntry.SpiffeId) - resultStatus := api.OK() + resultStatus := commonapi.OK() regEntry, existing, err := s.ds.CreateOrReturnRegistrationEntry(ctx, cEntry) switch { case err != nil: @@ -296,16 +297,16 @@ func (s *Service) createEntry(ctx context.Context, e *types.Entry, outputMask *t statusCode = codes.Internal } return &entryv1.BatchCreateEntryResponse_Result{ - Status: api.MakeStatus(log, statusCode, "failed to create entry", err), + Status: commonapi.MakeStatus(log, statusCode, "failed to create entry", err), } case existing: - resultStatus = api.CreateStatus(codes.AlreadyExists, "similar entry already exists") + resultStatus = commonapi.CreateStatus(codes.AlreadyExists, "similar entry already exists") } tEntry, err := api.RegistrationEntryToProto(regEntry) if err != nil { return &entryv1.BatchCreateEntryResponse_Result{ - Status: api.MakeStatus(log, codes.Internal, "failed to convert entry", err), + Status: commonapi.MakeStatus(log, codes.Internal, "failed to convert entry", err), } } @@ -356,7 +357,7 @@ func (s *Service) deleteEntry(ctx context.Context, id string) *entryv1.BatchDele if id == "" { return &entryv1.BatchDeleteEntryResponse_Result{ Id: id, - Status: api.MakeStatus(log, codes.InvalidArgument, "missing entry ID", nil), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "missing entry ID", nil), } } @@ -367,17 +368,17 @@ func (s *Service) deleteEntry(ctx context.Context, id string) *entryv1.BatchDele case codes.OK: return &entryv1.BatchDeleteEntryResponse_Result{ Id: id, - Status: api.OK(), + Status: commonapi.OK(), } case codes.NotFound: return &entryv1.BatchDeleteEntryResponse_Result{ Id: id, - Status: api.MakeStatus(log, codes.NotFound, "entry not found", nil), + Status: commonapi.MakeStatus(log, codes.NotFound, "entry not found", nil), } default: return &entryv1.BatchDeleteEntryResponse_Result{ Id: id, - Status: api.MakeStatus(log, codes.Internal, "failed to delete entry", err), + Status: commonapi.MakeStatus(log, codes.Internal, "failed to delete entry", err), } } } @@ -560,12 +561,12 @@ func SyncAuthorizedEntries(stream entryv1.Entry_SyncAuthorizedEntriesServer, ent func (s *Service) fetchEntries(ctx context.Context, log logrus.FieldLogger) ([]api.ReadOnlyEntry, error) { callerID, ok := rpccontext.CallerID(ctx) if !ok { - return nil, api.MakeErr(log, codes.Internal, "caller ID missing from request context", nil) + return nil, commonapi.MakeErr(log, codes.Internal, "caller ID missing from request context", nil) } entries, err := s.ef.FetchAuthorizedEntries(ctx, callerID) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to fetch entries", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to fetch entries", err) } return entries, nil @@ -644,7 +645,7 @@ func (s *Service) updateEntry(ctx context.Context, e *types.Entry, inputMask *ty convEntry, err := api.ProtoToRegistrationEntryWithMask(ctx, s.td, e, inputMask) if err != nil { return &entryv1.BatchUpdateEntryResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "failed to convert entry", err), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "failed to convert entry", err), } } @@ -673,21 +674,21 @@ func (s *Service) updateEntry(ctx context.Context, e *types.Entry, inputMask *ty statusCode = codes.Internal } return &entryv1.BatchUpdateEntryResponse_Result{ - Status: api.MakeStatus(log, statusCode, "failed to update entry", err), + Status: commonapi.MakeStatus(log, statusCode, "failed to update entry", err), } } tEntry, err := api.RegistrationEntryToProto(dsEntry) if err != nil { return &entryv1.BatchUpdateEntryResponse_Result{ - Status: api.MakeStatus(log, codes.Internal, "failed to convert entry in updateEntry", err), + Status: commonapi.MakeStatus(log, codes.Internal, "failed to convert entry in updateEntry", err), } } applyMask(tEntry, outputMask) return &entryv1.BatchUpdateEntryResponse_Result{ - Status: api.OK(), + Status: commonapi.OK(), Entry: tEntry, } } diff --git a/pkg/server/api/entry/v1/service_test.go b/pkg/server/api/entry/v1/service_test.go index b2fb24181d..6311c79b8b 100644 --- a/pkg/server/api/entry/v1/service_test.go +++ b/pkg/server/api/entry/v1/service_test.go @@ -17,6 +17,7 @@ import ( "github.com/spiffe/go-spiffe/v2/spiffeid" entryv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/server/entry/v1" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + commonapi "github.com/spiffe/spire/pkg/common/api" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/server/api" "github.com/spiffe/spire/pkg/server/api/entry/v1" @@ -4730,7 +4731,7 @@ func TestBatchUpdateEntry(t *testing.T) { spiffeToIDMap := make(map[string]string) updateEntries := tt.updateEntries for i := range createResp.Results { - require.Equal(t, api.OK(), createResp.Results[i].Status) + require.Equal(t, commonapi.OK(), createResp.Results[i].Status) updateEntries[i].Id = createResp.Results[i].Entry.Id spiffeToIDMap[createResp.Results[i].Entry.SpiffeId.Path] = createResp.Results[i].Entry.Id } diff --git a/pkg/server/api/health/v1/service.go b/pkg/server/api/health/v1/service.go index 5a2df7dd70..b621849bb0 100644 --- a/pkg/server/api/health/v1/service.go +++ b/pkg/server/api/health/v1/service.go @@ -4,8 +4,8 @@ import ( "context" "github.com/spiffe/go-spiffe/v2/spiffeid" + commonapi "github.com/spiffe/spire/pkg/common/api" "github.com/spiffe/spire/pkg/common/telemetry" - "github.com/spiffe/spire/pkg/server/api" "github.com/spiffe/spire/pkg/server/api/rpccontext" "github.com/spiffe/spire/pkg/server/datastore" "google.golang.org/grpc" @@ -45,7 +45,7 @@ func (s *Service) Check(ctx context.Context, req *grpc_health_v1.HealthCheckRequ // Ensure per-service health is not being requested. if req.Service != "" { - return nil, api.MakeErr(log, codes.InvalidArgument, "per-service health is not supported", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "per-service health is not supported", nil) } bundle, err := s.ds.FetchBundle(ctx, s.td.IDString()) diff --git a/pkg/server/api/localauthority/v1/service.go b/pkg/server/api/localauthority/v1/service.go index d2ab6e87c2..00270a0dc0 100644 --- a/pkg/server/api/localauthority/v1/service.go +++ b/pkg/server/api/localauthority/v1/service.go @@ -9,8 +9,8 @@ import ( "github.com/sirupsen/logrus" "github.com/spiffe/go-spiffe/v2/spiffeid" localauthorityv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/server/localauthority/v1" + commonapi "github.com/spiffe/spire/pkg/common/api" "github.com/spiffe/spire/pkg/common/telemetry" - "github.com/spiffe/spire/pkg/server/api" "github.com/spiffe/spire/pkg/server/api/rpccontext" "github.com/spiffe/spire/pkg/server/ca/manager" "github.com/spiffe/spire/pkg/server/datastore" @@ -70,15 +70,15 @@ type Service struct { func (s *Service) GetJWTAuthorityState(ctx context.Context, _ *localauthorityv1.GetJWTAuthorityStateRequest) (*localauthorityv1.GetJWTAuthorityStateResponse, error) { log := rpccontext.Logger(ctx) if s.isJWTSVIDsDisabled() { - return nil, api.MakeErr(log, codes.Unimplemented, "JWT functionality is disabled", nil) + return nil, commonapi.MakeErr(log, codes.Unimplemented, "JWT functionality is disabled", nil) } current := s.ca.GetCurrentJWTKeySlot() switch { case current.Status() != journal.Status_ACTIVE: - return nil, api.MakeErr(log, codes.Unavailable, "server is initializing", nil) + return nil, commonapi.MakeErr(log, codes.Unavailable, "server is initializing", nil) case current.AuthorityID() == "": - return nil, api.MakeErr(log, codes.Internal, "current slot does not contain authority ID", nil) + return nil, commonapi.MakeErr(log, codes.Internal, "current slot does not contain authority ID", nil) } resp := &localauthorityv1.GetJWTAuthorityStateResponse{ @@ -107,16 +107,16 @@ func (s *Service) GetJWTAuthorityState(ctx context.Context, _ *localauthorityv1. func (s *Service) PrepareJWTAuthority(ctx context.Context, _ *localauthorityv1.PrepareJWTAuthorityRequest) (*localauthorityv1.PrepareJWTAuthorityResponse, error) { log := rpccontext.Logger(ctx) if s.isJWTSVIDsDisabled() { - return nil, api.MakeErr(log, codes.Unimplemented, "JWT functionality is disabled", nil) + return nil, commonapi.MakeErr(log, codes.Unimplemented, "JWT functionality is disabled", nil) } current := s.ca.GetCurrentJWTKeySlot() if current.Status() != journal.Status_ACTIVE { - return nil, api.MakeErr(log, codes.Unavailable, "server is initializing", nil) + return nil, commonapi.MakeErr(log, codes.Unavailable, "server is initializing", nil) } if err := s.ca.PrepareJWTKey(ctx); err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to prepare JWT authority", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to prepare JWT authority", err) } slot := s.ca.GetNextJWTKeySlot() @@ -136,7 +136,7 @@ func (s *Service) ActivateJWTAuthority(ctx context.Context, req *localauthorityv log := rpccontext.Logger(ctx) if s.isJWTSVIDsDisabled() { - return nil, api.MakeErr(log, codes.Unimplemented, "JWT functionality is disabled", nil) + return nil, commonapi.MakeErr(log, codes.Unimplemented, "JWT functionality is disabled", nil) } if req.AuthorityId != "" { @@ -148,15 +148,15 @@ func (s *Service) ActivateJWTAuthority(ctx context.Context, req *localauthorityv switch { // Authority ID is required case req.AuthorityId == "": - return nil, api.MakeErr(log, codes.InvalidArgument, "no authority ID provided", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "no authority ID provided", nil) /// Only next local authority can be Activated case req.AuthorityId != nextSlot.AuthorityID(): - return nil, api.MakeErr(log, codes.InvalidArgument, "unexpected authority ID", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "unexpected authority ID", nil) // Only PREPARED local authorities can be Activated case nextSlot.Status() != journal.Status_PREPARED: - return nil, api.MakeErr(log, codes.Internal, "only Prepared authorities can be activated", fmt.Errorf("unsupported local authority status: %v", nextSlot.Status())) + return nil, commonapi.MakeErr(log, codes.Internal, "only Prepared authorities can be activated", fmt.Errorf("unsupported local authority status: %v", nextSlot.Status())) } s.ca.RotateJWTKey(ctx) @@ -178,7 +178,7 @@ func (s *Service) TaintJWTAuthority(ctx context.Context, req *localauthorityv1.T log := rpccontext.Logger(ctx) if s.isJWTSVIDsDisabled() { - return nil, api.MakeErr(log, codes.Unimplemented, "JWT functionality is disabled", nil) + return nil, commonapi.MakeErr(log, codes.Unimplemented, "JWT functionality is disabled", nil) } if req.AuthorityId != "" { @@ -190,23 +190,23 @@ func (s *Service) TaintJWTAuthority(ctx context.Context, req *localauthorityv1.T switch { // Authority ID is required case req.AuthorityId == "": - return nil, api.MakeErr(log, codes.InvalidArgument, "no authority ID provided", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "no authority ID provided", nil) // It is not possible to taint Active authority case req.AuthorityId == s.ca.GetCurrentJWTKeySlot().AuthorityID(): - return nil, api.MakeErr(log, codes.InvalidArgument, "unable to taint current local authority", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "unable to taint current local authority", nil) // Only next local authority can be tainted case req.AuthorityId != nextSlot.AuthorityID(): - return nil, api.MakeErr(log, codes.InvalidArgument, "unexpected authority ID", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "unexpected authority ID", nil) // Only OLD authorities can be tainted case nextSlot.Status() != journal.Status_OLD: - return nil, api.MakeErr(log, codes.InvalidArgument, "only Old local authorities can be tainted", fmt.Errorf("unsupported local authority status: %v", nextSlot.Status())) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "only Old local authorities can be tainted", fmt.Errorf("unsupported local authority status: %v", nextSlot.Status())) } if _, err := s.ds.TaintJWTKey(ctx, s.td.IDString(), nextSlot.AuthorityID()); err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to taint JWT authority", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to taint JWT authority", err) } state := &localauthorityv1.AuthorityState{ @@ -225,7 +225,7 @@ func (s *Service) RevokeJWTAuthority(ctx context.Context, req *localauthorityv1. rpccontext.AddRPCAuditFields(ctx, buildAuditLogFields(req.AuthorityId)) log := rpccontext.Logger(ctx) if s.isJWTSVIDsDisabled() { - return nil, api.MakeErr(log, codes.Unimplemented, "JWT functionality is disabled", nil) + return nil, commonapi.MakeErr(log, codes.Unimplemented, "JWT functionality is disabled", nil) } authorityID := req.AuthorityId @@ -234,12 +234,12 @@ func (s *Service) RevokeJWTAuthority(ctx context.Context, req *localauthorityv1. if req.AuthorityId != "" { log = log.WithField(telemetry.LocalAuthorityID, req.AuthorityId) } - return nil, api.MakeErr(log, codes.InvalidArgument, "invalid authority ID", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "invalid authority ID", err) } log = log.WithField(telemetry.LocalAuthorityID, authorityID) if _, err := s.ds.RevokeJWTKey(ctx, s.td.IDString(), authorityID); err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to revoke JWT authority", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to revoke JWT authority", err) } state := &localauthorityv1.AuthorityState{ @@ -260,9 +260,9 @@ func (s *Service) GetX509AuthorityState(ctx context.Context, _ *localauthorityv1 current := s.ca.GetCurrentX509CASlot() switch { case current.Status() != journal.Status_ACTIVE: - return nil, api.MakeErr(log, codes.Unavailable, "server is initializing", nil) + return nil, commonapi.MakeErr(log, codes.Unavailable, "server is initializing", nil) case current.AuthorityID() == "": - return nil, api.MakeErr(log, codes.Internal, "current slot does not contain authority ID", nil) + return nil, commonapi.MakeErr(log, codes.Internal, "current slot does not contain authority ID", nil) } resp := &localauthorityv1.GetX509AuthorityStateResponse{ @@ -292,11 +292,11 @@ func (s *Service) PrepareX509Authority(ctx context.Context, _ *localauthorityv1. current := s.ca.GetCurrentX509CASlot() if current.Status() != journal.Status_ACTIVE { - return nil, api.MakeErr(log, codes.Unavailable, "server is initializing", nil) + return nil, commonapi.MakeErr(log, codes.Unavailable, "server is initializing", nil) } if err := s.ca.PrepareX509CA(ctx); err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to prepare X.509 authority", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to prepare X.509 authority", err) } slot := s.ca.GetNextX509CASlot() @@ -324,15 +324,15 @@ func (s *Service) ActivateX509Authority(ctx context.Context, req *localauthority switch { // Authority ID is required case req.AuthorityId == "": - return nil, api.MakeErr(log, codes.InvalidArgument, "no authority ID provided", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "no authority ID provided", nil) /// Only next local authority can be Activated case req.AuthorityId != nextSlot.AuthorityID(): - return nil, api.MakeErr(log, codes.InvalidArgument, "unexpected authority ID", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "unexpected authority ID", nil) // Only PREPARED local authorities can be Activated case nextSlot.Status() != journal.Status_PREPARED: - return nil, api.MakeErr(log, codes.Internal, "only Prepared authorities can be activated", fmt.Errorf("unsupported local authority status: %v", nextSlot.Status())) + return nil, commonapi.MakeErr(log, codes.Internal, "only Prepared authorities can be activated", fmt.Errorf("unsupported local authority status: %v", nextSlot.Status())) } // Move next into current and reset next to clean CA @@ -359,7 +359,7 @@ func (s *Service) TaintX509Authority(ctx context.Context, req *localauthorityv1. } if s.ca.IsUpstreamAuthority() { - return nil, api.MakeErr(log, codes.FailedPrecondition, "local authority can't be tainted if there is an upstream authority", nil) + return nil, commonapi.MakeErr(log, codes.FailedPrecondition, "local authority can't be tainted if there is an upstream authority", nil) } nextSlot := s.ca.GetNextX509CASlot() @@ -367,23 +367,23 @@ func (s *Service) TaintX509Authority(ctx context.Context, req *localauthorityv1. switch { // Authority ID is required case req.AuthorityId == "": - return nil, api.MakeErr(log, codes.InvalidArgument, "no authority ID provided", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "no authority ID provided", nil) // It is not possible to taint Active authority case req.AuthorityId == s.ca.GetCurrentX509CASlot().AuthorityID(): - return nil, api.MakeErr(log, codes.InvalidArgument, "unable to taint current local authority", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "unable to taint current local authority", nil) // Only next local authority can be tainted case req.AuthorityId != nextSlot.AuthorityID(): - return nil, api.MakeErr(log, codes.InvalidArgument, "unexpected authority ID", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "unexpected authority ID", nil) // Only OLD authorities can be tainted case nextSlot.Status() != journal.Status_OLD: - return nil, api.MakeErr(log, codes.InvalidArgument, "only Old local authorities can be tainted", fmt.Errorf("unsupported local authority status: %v", nextSlot.Status())) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "only Old local authorities can be tainted", fmt.Errorf("unsupported local authority status: %v", nextSlot.Status())) } if err := s.ds.TaintX509CA(ctx, s.td.IDString(), nextSlot.AuthorityID()); err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to taint X.509 authority", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to taint X.509 authority", err) } state := &localauthorityv1.AuthorityState{ @@ -393,7 +393,7 @@ func (s *Service) TaintX509Authority(ctx context.Context, req *localauthorityv1. } if err := s.ca.NotifyTaintedX509Authority(ctx, nextSlot.AuthorityID()); err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to notify tainted authority", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to notify tainted authority", err) } rpccontext.AuditRPC(ctx) @@ -413,22 +413,22 @@ func (s *Service) TaintX509UpstreamAuthority(ctx context.Context, req *localauth } if !s.ca.IsUpstreamAuthority() { - return nil, api.MakeErr(log, codes.FailedPrecondition, "upstream authority is not configured", nil) + return nil, commonapi.MakeErr(log, codes.FailedPrecondition, "upstream authority is not configured", nil) } // TODO: may we request in lower case? // Normalize SKID subjectKeyIDRequest := strings.ToLower(req.SubjectKeyId) if err := s.validateUpstreamAuthoritySubjectKey(subjectKeyIDRequest); err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "provided subject key id is not valid", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "provided subject key id is not valid", err) } if err := s.ds.TaintX509CA(ctx, s.td.IDString(), subjectKeyIDRequest); err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to taint upstream authority", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to taint upstream authority", err) } if err := s.ca.NotifyTaintedX509Authority(ctx, subjectKeyIDRequest); err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to notify tainted authority", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to notify tainted authority", err) } rpccontext.AuditRPC(ctx) @@ -448,16 +448,16 @@ func (s *Service) RevokeX509Authority(ctx context.Context, req *localauthorityv1 } if s.ca.IsUpstreamAuthority() { - return nil, api.MakeErr(log, codes.FailedPrecondition, "local authority can't be revoked if there is an upstream authority", nil) + return nil, commonapi.MakeErr(log, codes.FailedPrecondition, "local authority can't be revoked if there is an upstream authority", nil) } if err := s.validateLocalAuthorityID(req.AuthorityId); err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "invalid authority ID", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "invalid authority ID", err) } log = log.WithField(telemetry.LocalAuthorityID, req.AuthorityId) if err := s.ds.RevokeX509CA(ctx, s.td.IDString(), req.AuthorityId); err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to revoke X.509 authority", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to revoke X.509 authority", err) } state := &localauthorityv1.AuthorityState{ @@ -481,18 +481,18 @@ func (s *Service) RevokeX509UpstreamAuthority(ctx context.Context, req *localaut } if !s.ca.IsUpstreamAuthority() { - return nil, api.MakeErr(log, codes.FailedPrecondition, "upstream authority is not configured", nil) + return nil, commonapi.MakeErr(log, codes.FailedPrecondition, "upstream authority is not configured", nil) } // TODO: may we request in lower case? // Normalize SKID subjectKeyIDRequest := strings.ToLower(req.SubjectKeyId) if err := s.validateUpstreamAuthoritySubjectKey(subjectKeyIDRequest); err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "invalid subject key ID", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "invalid subject key ID", err) } if err := s.ds.RevokeX509CA(ctx, s.td.IDString(), subjectKeyIDRequest); err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to revoke X.509 upstream authority", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to revoke X.509 upstream authority", err) } rpccontext.AuditRPC(ctx) @@ -505,27 +505,27 @@ func (s *Service) RevokeX509UpstreamAuthority(ctx context.Context, req *localaut func (s *Service) GetWITAuthorityState(ctx context.Context, _ *localauthorityv1.GetWITAuthorityStateRequest) (*localauthorityv1.GetWITAuthorityStateResponse, error) { log := rpccontext.Logger(ctx) - return nil, api.MakeErr(log, codes.Unimplemented, "WIT-SVID functionality is not yet implemented", nil) + return nil, commonapi.MakeErr(log, codes.Unimplemented, "WIT-SVID functionality is not yet implemented", nil) } func (s *Service) PrepareWITAuthority(ctx context.Context, _ *localauthorityv1.PrepareWITAuthorityRequest) (*localauthorityv1.PrepareWITAuthorityResponse, error) { log := rpccontext.Logger(ctx) - return nil, api.MakeErr(log, codes.Unimplemented, "WIT-SVID functionality is not yet implemented", nil) + return nil, commonapi.MakeErr(log, codes.Unimplemented, "WIT-SVID functionality is not yet implemented", nil) } func (s *Service) ActivateWITAuthority(ctx context.Context, req *localauthorityv1.ActivateWITAuthorityRequest) (*localauthorityv1.ActivateWITAuthorityResponse, error) { log := rpccontext.Logger(ctx) - return nil, api.MakeErr(log, codes.Unimplemented, "WIT-SVID functionality is not yet implemented", nil) + return nil, commonapi.MakeErr(log, codes.Unimplemented, "WIT-SVID functionality is not yet implemented", nil) } func (s *Service) TaintWITAuthority(ctx context.Context, req *localauthorityv1.TaintWITAuthorityRequest) (*localauthorityv1.TaintWITAuthorityResponse, error) { log := rpccontext.Logger(ctx) - return nil, api.MakeErr(log, codes.Unimplemented, "WIT-SVID functionality is not yet implemented", nil) + return nil, commonapi.MakeErr(log, codes.Unimplemented, "WIT-SVID functionality is not yet implemented", nil) } func (s *Service) RevokeWITAuthority(ctx context.Context, req *localauthorityv1.RevokeWITAuthorityRequest) (*localauthorityv1.RevokeWITAuthorityResponse, error) { log := rpccontext.Logger(ctx) - return nil, api.MakeErr(log, codes.Unimplemented, "WIT-SVID functionality is not yet implemented", nil) + return nil, commonapi.MakeErr(log, codes.Unimplemented, "WIT-SVID functionality is not yet implemented", nil) } func (s *Service) isJWTSVIDsDisabled() bool { diff --git a/pkg/server/api/logger/v1/levels.go b/pkg/server/api/logger/v1/levels.go deleted file mode 100644 index 1074d7b931..0000000000 --- a/pkg/server/api/logger/v1/levels.go +++ /dev/null @@ -1,26 +0,0 @@ -package logger - -import ( - "github.com/sirupsen/logrus" - apitype "github.com/spiffe/spire-api-sdk/proto/spire/api/types" -) - -var APILevel = map[logrus.Level]apitype.LogLevel{ - logrus.PanicLevel: apitype.LogLevel_PANIC, - logrus.FatalLevel: apitype.LogLevel_FATAL, - logrus.ErrorLevel: apitype.LogLevel_ERROR, - logrus.WarnLevel: apitype.LogLevel_WARN, - logrus.InfoLevel: apitype.LogLevel_INFO, - logrus.DebugLevel: apitype.LogLevel_DEBUG, - logrus.TraceLevel: apitype.LogLevel_TRACE, -} - -var LogrusLevel = map[apitype.LogLevel]logrus.Level{ - apitype.LogLevel_PANIC: logrus.PanicLevel, - apitype.LogLevel_FATAL: logrus.FatalLevel, - apitype.LogLevel_ERROR: logrus.ErrorLevel, - apitype.LogLevel_WARN: logrus.WarnLevel, - apitype.LogLevel_INFO: logrus.InfoLevel, - apitype.LogLevel_DEBUG: logrus.DebugLevel, - apitype.LogLevel_TRACE: logrus.TraceLevel, -} diff --git a/pkg/server/api/logger/v1/service.go b/pkg/server/api/logger/v1/service.go index 5d22224a99..600e055185 100644 --- a/pkg/server/api/logger/v1/service.go +++ b/pkg/server/api/logger/v1/service.go @@ -6,8 +6,9 @@ import ( "github.com/sirupsen/logrus" loggerv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/server/logger/v1" apitype "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + commonapi "github.com/spiffe/spire/pkg/common/api" + commonlogger "github.com/spiffe/spire/pkg/common/api/logger" "github.com/spiffe/spire/pkg/common/telemetry" - "github.com/spiffe/spire/pkg/server/api" "github.com/spiffe/spire/pkg/server/api/rpccontext" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -38,7 +39,7 @@ type Service struct { func New(c Config) *Service { launchLogLevel := c.Log.GetLevel() c.Log.WithFields(logrus.Fields{ - telemetry.LaunchLogLevel: launchLogLevel, + telemetry.LaunchLogLevel: launchLogLevel.String(), }).Info("Logger service configured") return &Service{ @@ -56,16 +57,16 @@ func (s *Service) GetLogger(ctx context.Context, _ *loggerv1.GetLoggerRequest) ( } func (s *Service) SetLogLevel(ctx context.Context, req *loggerv1.SetLogLevelRequest) (*apitype.Logger, error) { - rpccontext.AddRPCAuditFields(ctx, logrus.Fields{telemetry.NewLogLevel: req.NewLevel}) + rpccontext.AddRPCAuditFields(ctx, logrus.Fields{telemetry.NewLogLevel: req.NewLevel.String()}) log := rpccontext.Logger(ctx) if req.NewLevel == apitype.LogLevel_UNSPECIFIED { - return nil, api.MakeErr(log, codes.InvalidArgument, "newLevel value cannot be LogLevel_UNSPECIFIED", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "newLevel value cannot be LogLevel_UNSPECIFIED", nil) } - newLogLevel, ok := LogrusLevel[req.NewLevel] + newLogLevel, ok := commonlogger.LogrusLevel[req.NewLevel] if !ok { - return nil, api.MakeErr(log, codes.InvalidArgument, "unsupported log level", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "unsupported log level", nil) } log.WithFields(logrus.Fields{ @@ -79,7 +80,7 @@ func (s *Service) SetLogLevel(ctx context.Context, req *loggerv1.SetLogLevelRequ func (s *Service) ResetLogLevel(ctx context.Context, _ *loggerv1.ResetLogLevelRequest) (*apitype.Logger, error) { log := rpccontext.Logger(ctx) - log.WithField(telemetry.LaunchLogLevel, s.launchLevel).Info("ResetLogLevel Called") + log.WithField(telemetry.LaunchLogLevel, s.launchLevel.String()).Info("ResetLogLevel Called") s.log.SetLevel(s.launchLevel) @@ -89,7 +90,7 @@ func (s *Service) ResetLogLevel(ctx context.Context, _ *loggerv1.ResetLogLevelRe func (s *Service) createAPILogger() *apitype.Logger { return &apitype.Logger{ - CurrentLevel: APILevel[s.log.GetLevel()], - LaunchLevel: APILevel[s.launchLevel], + CurrentLevel: commonlogger.APILevel[s.log.GetLevel()], + LaunchLevel: commonlogger.APILevel[s.launchLevel], } } diff --git a/pkg/server/api/middleware/ratelimit.go b/pkg/server/api/middleware/ratelimit.go index 383d8864d5..0080bca673 100644 --- a/pkg/server/api/middleware/ratelimit.go +++ b/pkg/server/api/middleware/ratelimit.go @@ -4,11 +4,9 @@ import ( "context" "errors" "net" - "sync" - "time" - "github.com/andres-erbsen/clock" "github.com/spiffe/spire/pkg/common/api/middleware" + "github.com/spiffe/spire/pkg/common/ratelimit" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/server/api" "github.com/spiffe/spire/pkg/server/api/rpccontext" @@ -17,36 +15,22 @@ import ( "google.golang.org/grpc/status" ) -const ( - // gcInterval is the interval at which per-ip limiters are garbage - // collected. - gcInterval = time.Minute -) - -var ( - // Used to manipulate time in unit tests - clk = clock.New() -) - var ( // newRawRateLimiter is used to create a new ratelimiter. It returns a limiter // from the standard rate package by default production. - newRawRateLimiter = func(limit rate.Limit, burst int) rawRateLimiter { + newRawRateLimiter = func(limit rate.Limit, burst int) ratelimit.Limiter { return rate.NewLimiter(limit, burst) } ) +// perKeyLimiterOpts holds options for creating per-key limiters. +// Exposed for testing (e.g., clock injection). +var perKeyLimiterOpts []ratelimit.Option + type noopRateLimiter interface { noop() } -// rawRateLimiter represents the raw limiter functionality. -type rawRateLimiter interface { - WaitN(ctx context.Context, count int) error - Limit() rate.Limit - Burst() int -} - // NoLimit returns a rate limiter that does not rate limit. It is used to // configure methods that don't do rate limiting. func NoLimit() api.RateLimiter { @@ -112,7 +96,7 @@ func (disabledLimit) RateLimit(context.Context, int) error { func (disabledLimit) noop() {} type perCallLimiter struct { - limiter rawRateLimiter + limiter ratelimit.Limiter } func newPerCallLimiter(limit int) *perCallLimiter { @@ -124,25 +108,14 @@ func (lim *perCallLimiter) RateLimit(ctx context.Context, count int) error { } type perIPLimiter struct { - limit int - - mtx sync.RWMutex - - // previous holds all the limiters that were current at the GC - previous map[string]rawRateLimiter - - // current holds all the limiters that have been created or moved - // from the previous limiters since the last GC. - current map[string]rawRateLimiter - - // lastGC is the last GC - lastGC time.Time + inner *ratelimit.PerKeyLimiter } func newPerIPLimiter(limit int) *perIPLimiter { - return &perIPLimiter{limit: limit, - current: make(map[string]rawRateLimiter), - lastGC: clk.Now(), + return &perIPLimiter{ + inner: ratelimit.NewPerKeyLimiter(func() ratelimit.Limiter { + return newRawRateLimiter(rate.Limit(limit), limit) + }, perKeyLimiterOpts...), } } @@ -152,50 +125,10 @@ func (lim *perIPLimiter) RateLimit(ctx context.Context, count int) error { // Calls not via TCP/IP aren't limited return nil } - limiter := lim.getLimiter(tcpAddr.IP.String()) + limiter := lim.inner.GetLimiter(tcpAddr.IP.String()) return waitN(ctx, limiter, count) } -func (lim *perIPLimiter) getLimiter(ip string) rawRateLimiter { - lim.mtx.RLock() - limiter, ok := lim.current[ip] - if ok { - lim.mtx.RUnlock() - return limiter - } - lim.mtx.RUnlock() - - // A limiter does not exist for that address. - lim.mtx.Lock() - defer lim.mtx.Unlock() - - // Check the "current" entries in case another goroutine raced on this IP. - if limiter, ok = lim.current[ip]; ok { - return limiter - } - - // Then check the "previous" entries to see if a limiter exists for this - // IP as of the last GC. If so, move it to current and return it. - if limiter, ok = lim.previous[ip]; ok { - lim.current[ip] = limiter - delete(lim.previous, ip) - return limiter - } - - // There is no limiter for this IP. Before we create one, we should see - // if we need to do GC. - now := clk.Now() - if now.Sub(lim.lastGC) >= gcInterval { - lim.previous = lim.current - lim.current = make(map[string]rawRateLimiter) - lim.lastGC = now - } - - limiter = newRawRateLimiter(rate.Limit(lim.limit), lim.limit) - lim.current[ip] = limiter - return limiter -} - type rateLimitsMiddleware struct { limiters map[string]api.RateLimiter metrics telemetry.Metrics @@ -291,7 +224,7 @@ func getNames(ctx context.Context) []string { return []string{} } -func waitN(ctx context.Context, limiter rawRateLimiter, count int) (err error) { +func waitN(ctx context.Context, limiter ratelimit.Limiter, count int) (err error) { // limiter.WaitN already provides this check but the error returned is not // strongly typed and is a little messy. Lifting this check so we can // provide a clean error message. diff --git a/pkg/server/api/middleware/ratelimit_test.go b/pkg/server/api/middleware/ratelimit_test.go index a56ad2fa0b..0278cd7195 100644 --- a/pkg/server/api/middleware/ratelimit_test.go +++ b/pkg/server/api/middleware/ratelimit_test.go @@ -5,14 +5,16 @@ import ( "errors" "net" "testing" + "time" "github.com/sirupsen/logrus" "github.com/sirupsen/logrus/hooks/test" "github.com/spiffe/spire/pkg/common/api/middleware" + "github.com/spiffe/spire/pkg/common/ratelimit" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/server/api" "github.com/spiffe/spire/pkg/server/api/rpccontext" - "github.com/spiffe/spire/test/clock" + testclock "github.com/spiffe/spire/test/clock" "github.com/spiffe/spire/test/fakes/fakemetrics" "github.com/spiffe/spire/test/spiretest" "github.com/stretchr/testify/assert" @@ -113,7 +115,7 @@ func TestPerIPLimitGC(t *testing.T) { // Advance past the GC time and create for limiter for 3.3.3.3. This should // move both 1.1.1.1 and 2.2.2.2 into the "previous" set. There should be // three total limiters now. - mockClk.Add(gcInterval) + mockClk.Add(ratelimit.GCInterval) require.NoError(t, m.RateLimit(tcpCallerContext("3.3.3.3"), 1)) require.Equal(t, 3, limiters.Count) @@ -125,7 +127,7 @@ func TestPerIPLimitGC(t *testing.T) { // Advance to the next GC time. Create a limiter for 4.4.4.4. This should // cause 2.2.2.2 to be removed. 1.1.1.1 and 3.3.3.3 will go into the // "previous set". - mockClk.Add(gcInterval) + mockClk.Add(ratelimit.GCInterval) require.NoError(t, m.RateLimit(tcpCallerContext("4.4.4.4"), 1)) require.Equal(t, 4, limiters.Count) @@ -330,7 +332,7 @@ func NewFakeLimiters() *FakeLimiters { return ls } -func (ls *FakeLimiters) newRawRateLimiter(limit rate.Limit, burst int) rawRateLimiter { +func (ls *FakeLimiters) newRawRateLimiter(limit rate.Limit, burst int) ratelimit.Limiter { ls.Count++ return &fakeLimiter{ id: ls.Count, @@ -355,6 +357,10 @@ type fakeLimiter struct { burst int } +func (l *fakeLimiter) AllowN(_ time.Time, _ int) bool { + return true +} + func (l *fakeLimiter) WaitN(ctx context.Context, count int) error { switch { case l.limit == rate.Inf: @@ -390,11 +396,11 @@ func tcpCallerContext(ip string) context.Context { }) } -func setupClock(t *testing.T) (*clock.Mock, func()) { - mockClk := clock.NewMock(t) - oldClk := clk - clk = mockClk +func setupClock(t *testing.T) (*testclock.Mock, func()) { + mockClk := testclock.NewMock(t) + oldOpts := perKeyLimiterOpts + perKeyLimiterOpts = []ratelimit.Option{ratelimit.WithClock(mockClk)} return mockClk, func() { - clk = oldClk + perKeyLimiterOpts = oldOpts } } diff --git a/pkg/server/api/svid/v1/service.go b/pkg/server/api/svid/v1/service.go index 667fd62aaf..d297faa6d9 100644 --- a/pkg/server/api/svid/v1/service.go +++ b/pkg/server/api/svid/v1/service.go @@ -17,6 +17,7 @@ import ( "github.com/spiffe/go-spiffe/v2/spiffeid" svidv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/server/svid/v1" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + commonapi "github.com/spiffe/spire/pkg/common/api" "github.com/spiffe/spire/pkg/common/idutil" "github.com/spiffe/spire/pkg/common/jwtsvid" "github.com/spiffe/spire/pkg/common/telemetry" @@ -76,45 +77,45 @@ func (s *Service) MintX509SVID(ctx context.Context, req *svidv1.MintX509SVIDRequ }) if len(req.Csr) == 0 { - return nil, api.MakeErr(log, codes.InvalidArgument, "missing CSR", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "missing CSR", nil) } csr, err := x509.ParseCertificateRequest(req.Csr) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "malformed CSR", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "malformed CSR", err) } if err := csr.CheckSignature(); err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "failed to verify CSR signature", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "failed to verify CSR signature", err) } switch { case len(csr.URIs) == 0: - return nil, api.MakeErr(log, codes.InvalidArgument, "CSR URI SAN is required", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "CSR URI SAN is required", nil) case len(csr.URIs) > 1: - return nil, api.MakeErr(log, codes.InvalidArgument, "only one URI SAN is expected", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "only one URI SAN is expected", nil) } id, err := spiffeid.FromURI(csr.URIs[0]) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "CSR URI SAN is invalid", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "CSR URI SAN is invalid", err) } if err := api.VerifyTrustDomainWorkloadID(s.td, id); err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "CSR URI SAN is invalid", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "CSR URI SAN is invalid", err) } dnsNames := make([]string, 0, len(csr.DNSNames)) for _, dnsName := range csr.DNSNames { err := x509util.ValidateLabel(dnsName) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "CSR DNS name is invalid", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "CSR DNS name is invalid", err) } dnsNames = append(dnsNames, dnsName) } if err := x509util.CheckForWildcardOverlap(dnsNames); err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "CSR DNS name contains a wildcard that covers another non-wildcard name", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "CSR DNS name contains a wildcard that covers another non-wildcard name", err) } x509SVID, err := s.ca.SignWorkloadX509SVID(ctx, ca.WorkloadX509SVIDParams{ @@ -125,7 +126,7 @@ func (s *Service) MintX509SVID(ctx context.Context, req *svidv1.MintX509SVIDRequ Subject: csr.Subject, }) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to sign X509-SVID", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to sign X509-SVID", err) } commonX509SVIDLogFields := logrus.Fields{ @@ -156,7 +157,7 @@ func (s *Service) MintX509SVID(ctx context.Context, req *svidv1.MintX509SVIDRequ func (s *Service) MintJWTSVID(ctx context.Context, req *svidv1.MintJWTSVIDRequest) (*svidv1.MintJWTSVIDResponse, error) { log := rpccontext.Logger(ctx) if s.isJWTSVIDsDisabled() { - return nil, api.MakeErr(log, codes.Unimplemented, "JWT functionality is disabled", nil) + return nil, commonapi.MakeErr(log, codes.Unimplemented, "JWT functionality is disabled", nil) } rpccontext.AddRPCAuditFields(ctx, s.fieldsFromJWTSvidParams(ctx, req.Id, req.Audience, req.Ttl)) @@ -174,7 +175,7 @@ func (s *Service) MintJWTSVID(ctx context.Context, req *svidv1.MintJWTSVIDReques func (s *Service) MintWITSVID(ctx context.Context, req *svidv1.MintWITSVIDRequest) (*svidv1.MintWITSVIDResponse, error) { log := rpccontext.Logger(ctx) if s.isWITSVIDsDisabled() { - return nil, api.MakeErr(log, codes.Unimplemented, "WIT functionality is disabled", nil) + return nil, commonapi.MakeErr(log, codes.Unimplemented, "WIT functionality is disabled", nil) } rpccontext.AddRPCAuditFields(ctx, s.fieldsFromWITSvidParams(ctx, req.Id, req.Ttl)) @@ -193,11 +194,11 @@ func (s *Service) BatchNewX509SVID(ctx context.Context, req *svidv1.BatchNewX509 log := rpccontext.Logger(ctx) if len(req.Params) == 0 { - return nil, api.MakeErr(log, codes.InvalidArgument, "missing parameters", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "missing parameters", nil) } if err := rpccontext.RateLimit(ctx, len(req.Params)); err != nil { - return nil, api.MakeErr(log, status.Code(err), "rejecting request due to certificate signing rate limiting", err) + return nil, commonapi.MakeErr(log, status.Code(err), "rejecting request due to certificate signing rate limiting", err) } requestedEntries := make(map[string]struct{}) @@ -245,12 +246,12 @@ func (s *Service) BatchNewX509SVID(ctx context.Context, req *svidv1.BatchNewX509 func (s *Service) findEntries(ctx context.Context, log logrus.FieldLogger, entries map[string]struct{}) (map[string]api.ReadOnlyEntry, error) { callerID, ok := rpccontext.CallerID(ctx) if !ok { - return nil, api.MakeErr(log, codes.Internal, "caller ID missing from request context", nil) + return nil, commonapi.MakeErr(log, codes.Internal, "caller ID missing from request context", nil) } foundEntries, err := s.ef.LookupAuthorizedEntries(ctx, callerID, entries) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to fetch registration entries", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to fetch registration entries", err) } return foundEntries, nil } @@ -262,11 +263,11 @@ func (s *Service) newX509SVID(ctx context.Context, param *svidv1.NewX509SVIDPara switch { case param.EntryId == "": return &svidv1.BatchNewX509SVIDResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "missing entry ID", nil), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "missing entry ID", nil), } case len(param.Csr) == 0: return &svidv1.BatchNewX509SVIDResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "missing CSR", nil), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "missing CSR", nil), } } @@ -275,20 +276,20 @@ func (s *Service) newX509SVID(ctx context.Context, param *svidv1.NewX509SVIDPara entry, ok := entries[param.EntryId] if !ok { return &svidv1.BatchNewX509SVIDResponse_Result{ - Status: api.MakeStatus(log, codes.NotFound, "entry not found or not authorized", nil), + Status: commonapi.MakeStatus(log, codes.NotFound, "entry not found or not authorized", nil), } } csr, err := x509.ParseCertificateRequest(param.Csr) if err != nil { return &svidv1.BatchNewX509SVIDResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "malformed CSR", err), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "malformed CSR", err), } } if err := csr.CheckSignature(); err != nil { return &svidv1.BatchNewX509SVIDResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "invalid CSR signature", err), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "invalid CSR signature", err), } } @@ -296,7 +297,7 @@ func (s *Service) newX509SVID(ctx context.Context, param *svidv1.NewX509SVIDPara if err != nil { // This shouldn't be the case unless there is invalid data in the datastore return &svidv1.BatchNewX509SVIDResponse_Result{ - Status: api.MakeStatus(log, codes.Internal, "entry has malformed SPIFFE ID", err), + Status: commonapi.MakeStatus(log, codes.Internal, "entry has malformed SPIFFE ID", err), } } log = log.WithField(telemetry.SPIFFEID, spiffeID.String()) @@ -309,7 +310,7 @@ func (s *Service) newX509SVID(ctx context.Context, param *svidv1.NewX509SVIDPara }) if err != nil { return &svidv1.BatchNewX509SVIDResponse_Result{ - Status: api.MakeStatus(log, codes.Internal, "failed to sign X509-SVID", err), + Status: commonapi.MakeStatus(log, codes.Internal, "failed to sign X509-SVID", err), } } @@ -324,7 +325,7 @@ func (s *Service) newX509SVID(ctx context.Context, param *svidv1.NewX509SVIDPara CertChain: x509util.RawCertsFromCertificates(x509Svid), ExpiresAt: x509Svid[0].NotAfter.Unix(), }, - Status: api.OK(), + Status: commonapi.OK(), } } @@ -333,7 +334,7 @@ func (s *Service) mintJWTSVID(ctx context.Context, protoID *types.SPIFFEID, audi id, err := api.TrustDomainWorkloadIDFromProto(ctx, s.td, protoID) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "invalid SPIFFE ID", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "invalid SPIFFE ID", err) } log = log.WithField(telemetry.SPIFFEID, id.String()) @@ -342,7 +343,7 @@ func (s *Service) mintJWTSVID(ctx context.Context, protoID *types.SPIFFEID, audi }) if len(audience) == 0 { - return nil, api.MakeErr(log, codes.InvalidArgument, "at least one audience is required", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "at least one audience is required", nil) } token, err := s.ca.SignWorkloadJWTSVID(ctx, ca.WorkloadJWTSVIDParams{ @@ -352,12 +353,12 @@ func (s *Service) mintJWTSVID(ctx context.Context, protoID *types.SPIFFEID, audi IncludeJTI: includeJTI, }) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to sign JWT-SVID", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to sign JWT-SVID", err) } issuedAt, expiresAt, err := jwtsvid.GetTokenExpiry(token) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to get JWT-SVID expiry", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to get JWT-SVID expiry", err) } log.WithFields(logrus.Fields{ @@ -381,11 +382,11 @@ func (s *Service) NewJWTSVID(ctx context.Context, req *svidv1.NewJWTSVIDRequest) }) if err := rpccontext.RateLimit(ctx, 1); err != nil { - return nil, api.MakeErr(log, status.Code(err), "rejecting request due to JWT signing request rate limiting", err) + return nil, commonapi.MakeErr(log, status.Code(err), "rejecting request due to JWT signing request rate limiting", err) } if s.isJWTSVIDsDisabled() { - return nil, api.MakeErr(log, codes.Unimplemented, "JWT functionality is disabled", nil) + return nil, commonapi.MakeErr(log, codes.Unimplemented, "JWT functionality is disabled", nil) } entries := map[string]struct{}{ @@ -400,7 +401,7 @@ func (s *Service) NewJWTSVID(ctx context.Context, req *svidv1.NewJWTSVIDRequest) entry, ok := entriesMap[req.EntryId] if !ok { - return nil, api.MakeErr(log, codes.NotFound, "entry not found or not authorized", nil) + return nil, commonapi.MakeErr(log, codes.NotFound, "entry not found or not authorized", nil) } var includeJTI bool @@ -424,11 +425,11 @@ func (s *Service) BatchNewWITSVID(ctx context.Context, req *svidv1.BatchNewWITSV log := rpccontext.Logger(ctx) if len(req.Params) == 0 { - return nil, api.MakeErr(log, codes.InvalidArgument, "missing parameters", nil) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "missing parameters", nil) } if err := rpccontext.RateLimit(ctx, len(req.Params)); err != nil { - return nil, api.MakeErr(log, status.Code(err), "rejecting request due to certificate signing rate limiting", err) + return nil, commonapi.MakeErr(log, status.Code(err), "rejecting request due to certificate signing rate limiting", err) } requestedEntries := make(map[string]struct{}) @@ -477,18 +478,18 @@ func (s *Service) mintWITSVID(ctx context.Context, protoID *types.SPIFFEID, publ id, err := api.TrustDomainWorkloadIDFromProto(ctx, s.td, protoID) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "invalid SPIFFE ID", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "invalid SPIFFE ID", err) } log = log.WithField(telemetry.SPIFFEID, id.String()) publicKey, err := x509.ParsePKIXPublicKey(publicKeyDer) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "invalid public key", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "invalid public key", err) } if err := ValidatePublicKeyAndSigningAlgorithm(publicKey, signingAlgorithm); err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "invalid signing algorithm or key type", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "invalid signing algorithm or key type", err) } token, err := s.ca.SignWorkloadWITSVID(ctx, ca.WorkloadWITSVIDParams{ @@ -500,12 +501,12 @@ func (s *Service) mintWITSVID(ctx context.Context, protoID *types.SPIFFEID, publ }, }) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to sign WIT-SVID", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to sign WIT-SVID", err) } issuedAt, expiresAt, err := jwtsvid.GetTokenExpiry(token) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to get WIT-SVID expiry", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to get WIT-SVID expiry", err) } log.WithFields(logrus.Fields{ @@ -527,11 +528,11 @@ func (s *Service) newWITSVID(ctx context.Context, param *svidv1.NewWITSVIDParams switch { case param.EntryId == "": return &svidv1.BatchNewWITSVIDResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "missing entry ID", nil), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "missing entry ID", nil), } case len(param.PublicKey) == 0: return &svidv1.BatchNewWITSVIDResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "missing public key", nil), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "missing public key", nil), } } @@ -540,20 +541,20 @@ func (s *Service) newWITSVID(ctx context.Context, param *svidv1.NewWITSVIDParams entry, ok := entries[param.EntryId] if !ok { return &svidv1.BatchNewWITSVIDResponse_Result{ - Status: api.MakeStatus(log, codes.NotFound, "entry not found or not authorized", nil), + Status: commonapi.MakeStatus(log, codes.NotFound, "entry not found or not authorized", nil), } } publicKey, err := x509.ParsePKIXPublicKey(param.PublicKey) if err != nil { return &svidv1.BatchNewWITSVIDResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "malformed public key", err), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "malformed public key", err), } } if err := ValidatePublicKeyAndSigningAlgorithm(publicKey, param.SigningAlgorithm); err != nil { return &svidv1.BatchNewWITSVIDResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "invalid signing algorithm or key type", err), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "invalid signing algorithm or key type", err), } } @@ -561,7 +562,7 @@ func (s *Service) newWITSVID(ctx context.Context, param *svidv1.NewWITSVIDParams if err != nil { // This shouldn't be the case unless there is invalid data in the datastore return &svidv1.BatchNewWITSVIDResponse_Result{ - Status: api.MakeStatus(log, codes.Internal, "entry has malformed SPIFFE ID", err), + Status: commonapi.MakeStatus(log, codes.Internal, "entry has malformed SPIFFE ID", err), } } @@ -578,14 +579,14 @@ func (s *Service) newWITSVID(ctx context.Context, param *svidv1.NewWITSVIDParams }) if err != nil { return &svidv1.BatchNewWITSVIDResponse_Result{ - Status: api.MakeStatus(log, codes.Internal, "failed to sign WIT-SVID", err), + Status: commonapi.MakeStatus(log, codes.Internal, "failed to sign WIT-SVID", err), } } issuedAt, expiresAt, err := jwtsvid.GetTokenExpiry(witSvid) if err != nil { return &svidv1.BatchNewWITSVIDResponse_Result{ - Status: api.MakeStatus(log, codes.Internal, "failed to get WIT-SVID expiry", err), + Status: commonapi.MakeStatus(log, codes.Internal, "failed to get WIT-SVID expiry", err), } } @@ -607,12 +608,12 @@ func (s *Service) NewDownstreamX509CA(ctx context.Context, req *svidv1.NewDownst }) if err := rpccontext.RateLimit(ctx, 1); err != nil { - return nil, api.MakeErr(log, status.Code(err), "rejecting request due to downstream CA signing rate limit", err) + return nil, commonapi.MakeErr(log, status.Code(err), "rejecting request due to downstream CA signing rate limit", err) } downstreamEntries, isDownstream := rpccontext.CallerDownstreamEntries(ctx) if !isDownstream { - return nil, api.MakeErr(log, codes.Internal, "caller is not a downstream workload", nil) + return nil, commonapi.MakeErr(log, codes.Internal, "caller is not a downstream workload", nil) } entry := downstreamEntries[0] @@ -638,7 +639,7 @@ func (s *Service) NewDownstreamX509CA(ctx context.Context, req *svidv1.NewDownst TTL: time.Duration(ttl) * time.Second, }) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to sign downstream X.509 CA", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to sign downstream X.509 CA", err) } log.WithFields(logrus.Fields{ @@ -648,11 +649,11 @@ func (s *Service) NewDownstreamX509CA(ctx context.Context, req *svidv1.NewDownst bundle, err := s.ds.FetchBundle(ctx, s.td.IDString()) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to fetch bundle", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to fetch bundle", err) } if bundle == nil { - return nil, api.MakeErr(log, codes.NotFound, "bundle not found", nil) + return nil, commonapi.MakeErr(log, codes.NotFound, "bundle not found", nil) } rawRootCerts := make([][]byte, 0, len(bundle.RootCas)) @@ -716,11 +717,11 @@ func parseAndCheckCSR(ctx context.Context, csrBytes []byte) (*x509.CertificateRe csr, err := x509.ParseCertificateRequest(csrBytes) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "malformed CSR", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "malformed CSR", err) } if err := csr.CheckSignature(); err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "invalid CSR signature", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "invalid CSR signature", err) } return csr, nil diff --git a/pkg/server/api/trustdomain/v1/service.go b/pkg/server/api/trustdomain/v1/service.go index 544acc2604..54fc6fcb82 100644 --- a/pkg/server/api/trustdomain/v1/service.go +++ b/pkg/server/api/trustdomain/v1/service.go @@ -6,6 +6,7 @@ import ( "github.com/sirupsen/logrus" "github.com/spiffe/go-spiffe/v2/spiffeid" + commonapi "github.com/spiffe/spire/pkg/common/api" "github.com/spiffe/spire/pkg/common/protoutil" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/server/api" @@ -72,7 +73,7 @@ func (s *Service) ListFederationRelationships(ctx context.Context, req *trustdom dsResp, err := s.ds.ListFederationRelationships(ctx, listReq) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to list federation relationships", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to list federation relationships", err) } resp := &trustdomainv1.ListFederationRelationshipsResponse{} @@ -83,7 +84,7 @@ func (s *Service) ListFederationRelationships(ctx context.Context, req *trustdom for _, fr := range dsResp.FederationRelationships { tFederationRelationship, err := api.FederationRelationshipToProto(fr, req.OutputMask) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "failed to convert datastore response", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "failed to convert datastore response", err) } resp.FederationRelationships = append(resp.FederationRelationships, tFederationRelationship) } @@ -99,22 +100,22 @@ func (s *Service) GetFederationRelationship(ctx context.Context, req *trustdomai trustDomain, err := spiffeid.TrustDomainFromString(req.TrustDomain) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "failed to parse trust domain", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "failed to parse trust domain", err) } dsResp, err := s.ds.FetchFederationRelationship(ctx, trustDomain) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to fetch federation relationship", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to fetch federation relationship", err) } // if the entry is not found, FetchFederationRelationship returns nil, nil if dsResp == nil { - return nil, api.MakeErr(log, codes.NotFound, "federation relationship does not exist", err) + return nil, commonapi.MakeErr(log, codes.NotFound, "federation relationship does not exist", err) } tFederationRelationship, err := api.FederationRelationshipToProto(dsResp, req.OutputMask) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to convert datastore response", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to convert datastore response", err) } rpccontext.AuditRPC(ctx) @@ -195,7 +196,7 @@ func (s *Service) RefreshBundle(ctx context.Context, req *trustdomainv1.RefreshB trustDomain, err := spiffeid.TrustDomainFromString(req.GetTrustDomain()) if err != nil { - return nil, api.MakeErr(log, codes.InvalidArgument, "failed to parse trust domain", err) + return nil, commonapi.MakeErr(log, codes.InvalidArgument, "failed to parse trust domain", err) } log = log.WithField(telemetry.TrustDomainID, trustDomain.Name()) @@ -203,10 +204,10 @@ func (s *Service) RefreshBundle(ctx context.Context, req *trustdomainv1.RefreshB isManagedByBm, err := s.br.RefreshBundleFor(ctx, trustDomain) if err != nil { - return nil, api.MakeErr(log, codes.Internal, "failed to refresh bundle", err) + return nil, commonapi.MakeErr(log, codes.Internal, "failed to refresh bundle", err) } if !isManagedByBm { - return nil, api.MakeErr(log, codes.NotFound, fmt.Sprintf("no relationship with trust domain %q", trustDomain), nil) + return nil, commonapi.MakeErr(log, codes.NotFound, fmt.Sprintf("no relationship with trust domain %q", trustDomain), nil) } log.Debug("Bundle refreshed") @@ -221,27 +222,27 @@ func (s *Service) createFederationRelationship(ctx context.Context, f *types.Fed dsFederationRelationship, err := api.ProtoToFederationRelationship(f) if err != nil { return &trustdomainv1.BatchCreateFederationRelationshipResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "failed to convert federation relationship", err), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "failed to convert federation relationship", err), } } if s.td.Compare(dsFederationRelationship.TrustDomain) == 0 { return &trustdomainv1.BatchCreateFederationRelationshipResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "unable to create federation relationship for server trust domain", nil), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "unable to create federation relationship for server trust domain", nil), } } resp, err := s.ds.CreateFederationRelationship(ctx, dsFederationRelationship) if err != nil { return &trustdomainv1.BatchCreateFederationRelationshipResponse_Result{ - Status: api.MakeStatus(log, codes.Internal, "failed to create federation relationship", err), + Status: commonapi.MakeStatus(log, codes.Internal, "failed to create federation relationship", err), } } tFederationRelationship, err := api.FederationRelationshipToProto(resp, outputMask) if err != nil { return &trustdomainv1.BatchCreateFederationRelationshipResponse_Result{ - Status: api.MakeStatus(log, codes.Internal, "failed to convert datastore response", err), + Status: commonapi.MakeStatus(log, codes.Internal, "failed to convert datastore response", err), } } @@ -253,7 +254,7 @@ func (s *Service) createFederationRelationship(ctx context.Context, f *types.Fed log.Debug("Federation relationship created") return &trustdomainv1.BatchCreateFederationRelationshipResponse_Result{ - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: tFederationRelationship, } } @@ -265,7 +266,7 @@ func (s *Service) updateFederationRelationship(ctx context.Context, fr *types.Fe dFederationRelationship, err := api.ProtoToFederationRelationship(fr) if err != nil { return &trustdomainv1.BatchUpdateFederationRelationshipResponse_Result{ - Status: api.MakeStatus(log, codes.InvalidArgument, "failed to convert federation relationship", err), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "failed to convert federation relationship", err), } } @@ -276,14 +277,14 @@ func (s *Service) updateFederationRelationship(ctx context.Context, fr *types.Fe resp, err := s.ds.UpdateFederationRelationship(ctx, dFederationRelationship, inputMask) if err != nil { return &trustdomainv1.BatchUpdateFederationRelationshipResponse_Result{ - Status: api.MakeStatus(log, codes.Internal, "failed to update federation relationship", err), + Status: commonapi.MakeStatus(log, codes.Internal, "failed to update federation relationship", err), } } tFederationRelationship, err := api.FederationRelationshipToProto(resp, outputMask) if err != nil { return &trustdomainv1.BatchUpdateFederationRelationshipResponse_Result{ - Status: api.MakeStatus(log, codes.Internal, "failed to convert federation relationship to proto", err), + Status: commonapi.MakeStatus(log, codes.Internal, "failed to convert federation relationship to proto", err), } } // Warning in case of SPIFFE endpoint that does not have a bundle @@ -293,7 +294,7 @@ func (s *Service) updateFederationRelationship(ctx context.Context, fr *types.Fe log.Debug("Federation relationship updated") return &trustdomainv1.BatchUpdateFederationRelationshipResponse_Result{ - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: tFederationRelationship, } } @@ -304,7 +305,7 @@ func (s *Service) deleteFederationRelationship(ctx context.Context, td string) * if td == "" { return &trustdomainv1.BatchDeleteFederationRelationshipResponse_Result{ TrustDomain: td, - Status: api.MakeStatus(log, codes.InvalidArgument, "missing trust domain", nil), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "missing trust domain", nil), } } @@ -314,7 +315,7 @@ func (s *Service) deleteFederationRelationship(ctx context.Context, td string) * if err != nil { return &trustdomainv1.BatchDeleteFederationRelationshipResponse_Result{ TrustDomain: td, - Status: api.MakeStatus(log, codes.InvalidArgument, "failed to parse trust domain", err), + Status: commonapi.MakeStatus(log, codes.InvalidArgument, "failed to parse trust domain", err), } } @@ -324,17 +325,17 @@ func (s *Service) deleteFederationRelationship(ctx context.Context, td string) * log.Debug("Federation relationship deleted") return &trustdomainv1.BatchDeleteFederationRelationshipResponse_Result{ TrustDomain: trustDomain.Name(), - Status: api.OK(), + Status: commonapi.OK(), } case codes.NotFound: return &trustdomainv1.BatchDeleteFederationRelationshipResponse_Result{ TrustDomain: trustDomain.Name(), - Status: api.MakeStatus(log, codes.NotFound, "federation relationship not found", nil), + Status: commonapi.MakeStatus(log, codes.NotFound, "federation relationship not found", nil), } default: return &trustdomainv1.BatchDeleteFederationRelationshipResponse_Result{ TrustDomain: trustDomain.Name(), - Status: api.MakeStatus(log, codes.Internal, "failed to delete federation relationship", err), + Status: commonapi.MakeStatus(log, codes.Internal, "failed to delete federation relationship", err), } } } diff --git a/pkg/server/api/trustdomain/v1/service_test.go b/pkg/server/api/trustdomain/v1/service_test.go index 24f777c44f..e38c0f4ebb 100644 --- a/pkg/server/api/trustdomain/v1/service_test.go +++ b/pkg/server/api/trustdomain/v1/service_test.go @@ -12,6 +12,7 @@ import ( "github.com/spiffe/go-spiffe/v2/spiffeid" trustdomainv1 "github.com/spiffe/spire-api-sdk/proto/spire/api/server/trustdomain/v1" "github.com/spiffe/spire-api-sdk/proto/spire/api/types" + commonapi "github.com/spiffe/spire/pkg/common/api" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/server/api" "github.com/spiffe/spire/pkg/server/api/middleware" @@ -549,7 +550,7 @@ func TestBatchCreateFederationRelationship(t *testing.T) { }, expectResults: []*trustdomainv1.BatchCreateFederationRelationshipResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: &types.FederationRelationship{ TrustDomain: "domain.test", BundleEndpointUrl: "https://federated-td-web.org/bundleendpoint", @@ -557,7 +558,7 @@ func TestBatchCreateFederationRelationship(t *testing.T) { }, }, { - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: &types.FederationRelationship{ TrustDomain: "domain2.test", BundleEndpointUrl: "https://federated-td-web.org/bundleendpoint2", @@ -610,7 +611,7 @@ func TestBatchCreateFederationRelationship(t *testing.T) { }, expectResults: []*trustdomainv1.BatchCreateFederationRelationshipResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: &types.FederationRelationship{ TrustDomain: "domain.test", BundleEndpointUrl: "https://federated-td-web.org/bundleendpoint", @@ -722,7 +723,7 @@ func TestBatchCreateFederationRelationship(t *testing.T) { }, expectResults: []*trustdomainv1.BatchCreateFederationRelationshipResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: &types.FederationRelationship{ TrustDomain: "domain.test", BundleEndpointUrl: "https://federated-td-web.org/bundleendpoint", @@ -768,7 +769,7 @@ func TestBatchCreateFederationRelationship(t *testing.T) { }, expectResults: []*trustdomainv1.BatchCreateFederationRelationshipResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: &types.FederationRelationship{ TrustDomain: "domain.test", }, @@ -1087,7 +1088,7 @@ func TestBatchDeleteFederationRelationship(t *testing.T) { expectDs: []string{fooFR.TrustDomain.Name()}, expectResults: []*trustdomainv1.BatchDeleteFederationRelationshipResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), TrustDomain: "bar.test", }, { @@ -1098,7 +1099,7 @@ func TestBatchDeleteFederationRelationship(t *testing.T) { TrustDomain: "not.found", }, { - Status: api.OK(), + Status: commonapi.OK(), TrustDomain: "baz.test", }, }, @@ -1441,7 +1442,7 @@ func TestBatchUpdateFederationRelationship(t *testing.T) { }, expectResults: []*trustdomainv1.BatchUpdateFederationRelationshipResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: &types.FederationRelationship{ TrustDomain: "foo.test", BundleEndpointUrl: "https://foo.test/newpath", @@ -1455,7 +1456,7 @@ func TestBatchUpdateFederationRelationship(t *testing.T) { }, }, { - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: &types.FederationRelationship{ TrustDomain: "bar.test", BundleEndpointUrl: "https://bar.test/newpath", @@ -1561,7 +1562,7 @@ func TestBatchUpdateFederationRelationship(t *testing.T) { }, expectResults: []*trustdomainv1.BatchUpdateFederationRelationshipResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: &types.FederationRelationship{ TrustDomain: "bar.test", BundleEndpointUrl: "https://bar.test/newpath", @@ -1673,7 +1674,7 @@ func TestBatchUpdateFederationRelationship(t *testing.T) { }, expectResults: []*trustdomainv1.BatchUpdateFederationRelationshipResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: &types.FederationRelationship{ TrustDomain: "foo.test", BundleEndpointUrl: "https://foo.test/newpath", @@ -1752,7 +1753,7 @@ func TestBatchUpdateFederationRelationship(t *testing.T) { inputMask: &types.FederationRelationshipMask{}, expectResults: []*trustdomainv1.BatchUpdateFederationRelationshipResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: &types.FederationRelationship{ TrustDomain: "bar.test", BundleEndpointUrl: "https://bar.test/path", @@ -1824,7 +1825,7 @@ func TestBatchUpdateFederationRelationship(t *testing.T) { outputMask: &types.FederationRelationshipMask{}, expectResults: []*trustdomainv1.BatchUpdateFederationRelationshipResponse_Result{ { - Status: api.OK(), + Status: commonapi.OK(), FederationRelationship: &types.FederationRelationship{ TrustDomain: "bar.test", }, diff --git a/pkg/server/common/vault/client.go b/pkg/server/common/vault/client.go index d212d6732e..e386e90bb3 100644 --- a/pkg/server/common/vault/client.go +++ b/pkg/server/common/vault/client.go @@ -12,9 +12,9 @@ import ( "os" "strings" + "dario.cat/mergo" "github.com/hashicorp/go-hclog" vapi "github.com/hashicorp/vault/api" - "github.com/imdario/mergo" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" diff --git a/pkg/server/config.go b/pkg/server/config.go index 82aaa70417..e5792014b9 100644 --- a/pkg/server/config.go +++ b/pkg/server/config.go @@ -144,6 +144,11 @@ type Config struct { // considered for pruning. PruneNonReattestableNodes bool + // PruneAttestedNodesBatchSize is the maximum number of expired attested + // nodes pruned per cycle. When non-positive, a default is used. Only applies + // when PruneAttestedNodesExpiredFor is set. + PruneAttestedNodesBatchSize int + // MaxAttestedNodeInfoStaleness determines how long to trust cached attested // node information, before requiring refreshing it from the datastore. MaxAttestedNodeInfoStaleness time.Duration diff --git a/pkg/server/datastore/datastore.go b/pkg/server/datastore/datastore.go index 63884bdb61..0f52a7a36a 100644 --- a/pkg/server/datastore/datastore.go +++ b/pkg/server/datastore/datastore.go @@ -52,9 +52,12 @@ type DataStore interface { CreateAttestedNode(context.Context, *common.AttestedNode) (*common.AttestedNode, error) DeleteAttestedNode(ctx context.Context, spiffeID string) (*common.AttestedNode, error) FetchAttestedNode(ctx context.Context, spiffeID string) (*common.AttestedNode, error) + // FetchAttestedNodes fetches the given nodes (with selectors) keyed by SPIFFE ID. + // IDs with no existing node are omitted from the map and may be treated as deleted. + FetchAttestedNodes(ctx context.Context, spiffeIDs []string) (map[string]*common.AttestedNode, error) ListAttestedNodes(context.Context, *ListAttestedNodesRequest) (*ListAttestedNodesResponse, error) UpdateAttestedNode(context.Context, *common.AttestedNode, *common.AttestedNodeMask) (*common.AttestedNode, error) - PruneAttestedExpiredNodes(ctx context.Context, expiredBefore time.Time, includeNonReattestable bool) error + PruneAttestedExpiredNodes(ctx context.Context, expiredBefore time.Time, includeNonReattestable bool, batchSize int) error // Nodes Events ListAttestedNodeEvents(ctx context.Context, req *ListAttestedNodeEventsRequest) (*ListAttestedNodeEventsResponse, error) @@ -161,6 +164,7 @@ type ListAttestedNodesRequest struct { ByBanned *bool ByExpiresBefore time.Time BySelectorMatch *BySelectors + BySpiffeIDs []string FetchSelectors bool Pagination *Pagination ByCanReattest *bool diff --git a/pkg/server/datastore/sqlstore/sqlstore.go b/pkg/server/datastore/sqlstore/sqlstore.go index e7618f9750..0f3a0b6f54 100644 --- a/pkg/server/datastore/sqlstore/sqlstore.go +++ b/pkg/server/datastore/sqlstore/sqlstore.go @@ -70,6 +70,11 @@ const ( // Maximum size for additional attributes message in a registration entry maxAdditionalAttributesSize = 65535 + + // defaultPruneAttestedNodesBatchSize is the number of expired attested + // nodes pruned per call when no batch size (or a non-positive one) is + // provided. + defaultPruneAttestedNodesBatchSize = 1000 ) // Configuration for the sql datastore implementation. @@ -317,6 +322,27 @@ func (ds *Plugin) FetchAttestedNode(ctx context.Context, spiffeID string) (attes return attestedNode, nil } +// FetchAttestedNodes fetches existing attested nodes by SPIFFE IDs, including their selectors +func (ds *Plugin) FetchAttestedNodes(ctx context.Context, spiffeIDs []string) (map[string]*common.AttestedNode, error) { + nodesMap := make(map[string]*common.AttestedNode) + if len(spiffeIDs) == 0 { + return nodesMap, nil + } + + resp, err := listAttestedNodes(ctx, ds.db, ds.log, &datastore.ListAttestedNodesRequest{ + BySpiffeIDs: spiffeIDs, + FetchSelectors: true, + }) + if err != nil { + return nil, err + } + + for _, node := range resp.Nodes { + nodesMap[node.SpiffeId] = node + } + return nodesMap, nil +} + // CountAttestedNodes counts all attested nodes func (ds *Plugin) CountAttestedNodes(ctx context.Context, req *datastore.CountAttestedNodesRequest) (count int32, err error) { if countAttestedNodesHasFilters(req) { @@ -365,7 +391,7 @@ func (ds *Plugin) UpdateAttestedNode(ctx context.Context, n *common.AttestedNode // DeleteAttestedNode deletes the given attested node and the associated node selectors. func (ds *Plugin) DeleteAttestedNode(ctx context.Context, spiffeID string) (attestedNode *common.AttestedNode, err error) { if err = ds.withWriteTx(ctx, func(tx *gorm.DB) (err error) { - attestedNode, err = deleteAttestedNodeAndSelectors(tx, spiffeID) + attestedNode, err = deleteAttestedNodeAndSelectors(tx, spiffeID, ds.log) if err != nil { return err } @@ -380,10 +406,11 @@ func (ds *Plugin) DeleteAttestedNode(ctx context.Context, spiffeID string) (atte // PruneAttestedExpiredNodes deletes attested nodes with expiration time further than a given duration in the past. // Non-reattestable nodes are not deleted by default, and have to be included explicitly by setting -// includeNonReattestable = true. Banned nodes are not deleted. -func (ds *Plugin) PruneAttestedExpiredNodes(ctx context.Context, expiredBefore time.Time, includeNonReattestable bool) error { +// includeNonReattestable = true. Banned nodes are not deleted. At most batchSize nodes are pruned per call; +// a non-positive batchSize falls back to the default. +func (ds *Plugin) PruneAttestedExpiredNodes(ctx context.Context, expiredBefore time.Time, includeNonReattestable bool, batchSize int) error { return ds.withWriteTx(ctx, func(tx *gorm.DB) (err error) { - return pruneAttestedExpiredNodes(tx, expiredBefore, includeNonReattestable, ds.log) + return pruneAttestedExpiredNodes(tx, expiredBefore, includeNonReattestable, batchSize, ds.log) }) } @@ -1825,10 +1852,14 @@ func includeNonReattestable(include bool) func(db *gorm.DB) *gorm.DB { } } -func pruneAttestedExpiredNodes(tx *gorm.DB, expiredBefore time.Time, include bool, logger logrus.FieldLogger) error { +func pruneAttestedExpiredNodes(tx *gorm.DB, expiredBefore time.Time, include bool, batchSize int, logger logrus.FieldLogger) error { var expiredNodes []AttestedNode - if err := tx.Scopes(expiredForDuration(expiredBefore), includeNonReattestable(include), notBanned).Limit(1000).Find(&expiredNodes).Error; err != nil { + if batchSize <= 0 { + batchSize = defaultPruneAttestedNodesBatchSize + } + + if err := tx.Scopes(expiredForDuration(expiredBefore), includeNonReattestable(include), notBanned).Limit(batchSize).Find(&expiredNodes).Error; err != nil { return newWrappedSQLError(err) } @@ -1836,7 +1867,7 @@ func pruneAttestedExpiredNodes(tx *gorm.DB, expiredBefore time.Time, include boo defer func() { logger.WithField("count", count).Info("Pruned expired agents") }() for _, node := range expiredNodes { - _, err := deleteAttestedNodeAndSelectors(tx, node.SpiffeID) + _, err := deleteAttestedNodeAndSelectors(tx, node.SpiffeID, logger) if err != nil { return err } @@ -2048,6 +2079,14 @@ func buildListAttestedNodesQueryCTE(req *datastore.ListAttestedNodesRequest, dbT } } + // Filter by a set of SPIFFE IDs + if len(req.BySpiffeIDs) > 0 { + builder.WriteString("\t\tAND spiffe_id IN (") + builder.WriteString(buildQuestions(req.BySpiffeIDs)) + builder.WriteString(")\n") + args = append(args, buildArgs(req.BySpiffeIDs)...) + } + builder.WriteString(")") // Fetch all selectors from filtered entries if fetchSelectors { @@ -2281,6 +2320,14 @@ FROM attested_node_entries N builder.WriteString("\t\tAND can_reattest = false\n") } } + + // Filter by a set of SPIFFE IDs + if len(req.BySpiffeIDs) > 0 { + builder.WriteString(" AND N.spiffe_id IN (") + builder.WriteString(buildQuestions(req.BySpiffeIDs)) + builder.WriteString(")") + args = append(args, buildArgs(req.BySpiffeIDs)...) + } return nil } @@ -2394,18 +2441,53 @@ func updateAttestedNode(tx *gorm.DB, n *common.AttestedNode, mask *common.Attest return modelToAttestedNode(model), nil } -func deleteAttestedNodeAndSelectors(tx *gorm.DB, spiffeID string) (*common.AttestedNode, error) { +func deleteAttestedNodeAndSelectors(tx *gorm.DB, spiffeID string, logger logrus.FieldLogger) (*common.AttestedNode, error) { var ( nodeModel AttestedNode nodeSelectorModel NodeSelector ) - // batch delete all associated node selectors - if err := tx.Where("spiffe_id = ?", spiffeID).Delete(&nodeSelectorModel).Error; err != nil { + if err := tx.Find(&nodeModel, "spiffe_id = ?", spiffeID).Error; err != nil { return nil, newWrappedSQLError(err) } - if err := tx.Find(&nodeModel, "spiffe_id = ?", spiffeID).Error; err != nil { + // Cascade only for join-token-attested nodes, and only for entries that match + // the auto-alias shape that (*Service).createJoinTokenRegistrationEntry in + // pkg/server/api/agent/v1/service.go writes (single "spiffe_id" selector whose + // value is the parent SVID). Keep this matcher in sync with that writer; the two + // are exercised together by TestCascadeDeleteJoinTokenAliasEntry. Other + // parent_id-keyed entries are user-managed workload entries and must be preserved. + if nodeModel.DataType == "join_token" { + var candidates []RegisteredEntry + if err := tx.Where("parent_id = ?", spiffeID).Find(&candidates).Error; err != nil { + return nil, newWrappedSQLError(err) + } + for _, entry := range candidates { + var selectors []Selector + if err := tx.Where("registered_entry_id = ?", entry.ID).Find(&selectors).Error; err != nil { + return nil, newWrappedSQLError(err) + } + if len(selectors) != 1 || selectors[0].Type != "spiffe_id" || selectors[0].Value != entry.ParentID { + continue + } + if err := deleteRegistrationEntrySupport(tx, entry); err != nil { + return nil, err + } + if err := createRegistrationEntryEvent(tx, &datastore.RegistrationEntryEvent{ + EntryID: entry.EntryID, + }); err != nil { + return nil, err + } + logger.WithFields(logrus.Fields{ + telemetry.SPIFFEID: entry.SpiffeID, + telemetry.ParentID: entry.ParentID, + telemetry.RegistrationID: entry.EntryID, + }).Info("Cascade-deleted registration entry on attested node deletion") + } + } + + // batch delete all associated node selectors + if err := tx.Where("spiffe_id = ?", spiffeID).Delete(&nodeSelectorModel).Error; err != nil { return nil, newWrappedSQLError(err) } @@ -2859,7 +2941,7 @@ FROM registered_entries WHERE id IN (SELECT id FROM listing) -UNION +UNION ALL SELECT F.registered_entry_id, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, B.trust_domain, NULL, NULL, NULL, NULL, NULL @@ -2872,7 +2954,7 @@ ON WHERE F.registered_entry_id IN (SELECT id FROM listing) -UNION +UNION ALL SELECT registered_entry_id, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, id, value, NULL, NULL, NULL @@ -2880,7 +2962,7 @@ FROM dns_names WHERE registered_entry_id IN (SELECT id FROM listing) -UNION +UNION ALL SELECT registered_entry_id, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, id, type, value, NULL, NULL, NULL, NULL, NULL, NULL diff --git a/pkg/server/datastore/sqlstore/sqlstore_test.go b/pkg/server/datastore/sqlstore/sqlstore_test.go index f36c923f96..85816a8ad7 100644 --- a/pkg/server/datastore/sqlstore/sqlstore_test.go +++ b/pkg/server/datastore/sqlstore/sqlstore_test.go @@ -930,6 +930,80 @@ func (s *PluginSuite) TestFetchAttestedNodeMissing() { s.Require().Nil(attestedNode) } +func (s *PluginSuite) TestFetchAttestedNodes() { + createNode := func(spiffeID string, selectors []*common.Selector) *common.AttestedNode { + node, err := s.ds.CreateAttestedNode(ctx, &common.AttestedNode{ + SpiffeId: spiffeID, + AttestationDataType: "aws-tag", + CertSerialNumber: "badcafe", + CertNotAfter: time.Now().Add(time.Hour).Unix(), + }) + s.Require().NoError(err) + s.setNodeSelectors(spiffeID, selectors) + node.Selectors = selectors + return node + } + + node1 := createNode("spiffe://example.org/node1", []*common.Selector{{Type: "a", Value: "1"}}) + node2 := createNode("spiffe://example.org/node2", []*common.Selector{{Type: "b", Value: "2"}}) + node3 := createNode("spiffe://example.org/node3", []*common.Selector{{Type: "c", Value: "3"}}) + + // Create a node and then delete it so we can test it doesn't get returned with the fetch + node4 := createNode("spiffe://example.org/node4", []*common.Selector{{Type: "d", Value: "4"}}) + deletedNode, err := s.ds.DeleteAttestedNode(ctx, node4.SpiffeId) + s.Require().NoError(err) + s.Require().NotNil(deletedNode) + + for _, tt := range []struct { + name string + nodes []*common.AttestedNode + deletedSpiffeID string + }{ + { + name: "No nodes", + }, + { + name: "Nodes 1 and 2", + nodes: []*common.AttestedNode{node1, node2}, + }, + { + name: "Nodes 1, 2, and 3", + nodes: []*common.AttestedNode{node1, node2, node3}, + }, + { + name: "Deleted node", + nodes: []*common.AttestedNode{node2, node3}, + deletedSpiffeID: deletedNode.SpiffeId, + }, + } { + s.T().Run(tt.name, func(t *testing.T) { + spiffeIDs := make([]string, 0, len(tt.nodes)) + for _, node := range tt.nodes { + spiffeIDs = append(spiffeIDs, node.SpiffeId) + } + fetchedNodes, err := s.ds.FetchAttestedNodes(ctx, append(spiffeIDs, tt.deletedSpiffeID)) + s.Require().NoError(err) + + // Make sure all nodes we want to fetch are present, including selectors. + s.Require().Equal(len(tt.nodes), len(fetchedNodes)) + for _, node := range tt.nodes { + fetchedNode, ok := fetchedNodes[node.SpiffeId] + s.Require().True(ok) + s.RequireProtoEqual(node, fetchedNode) + } + + // Make sure any deleted nodes are not present. + _, ok := fetchedNodes[tt.deletedSpiffeID] + s.Require().False(ok) + }) + } + + // An empty request returns an empty map. + fetchedNodes, err := s.ds.FetchAttestedNodes(ctx, nil) + s.Require().NoError(err) + s.Require().Empty(fetchedNodes) +} + func (s *PluginSuite) TestListAttestedNodes() { // Connection is never used, each test creates a connection to a different database s.ds.Close() @@ -1495,7 +1569,7 @@ func (s *PluginSuite) TestPruneAttestedExpiredNodes() { } s.Run("prune before expiry", func() { - err := s.ds.PruneAttestedExpiredNodes(ctx, now.Add(-time.Hour), false) + err := s.ds.PruneAttestedExpiredNodes(ctx, now.Add(-time.Hour), false, 0) s.Require().NoError(err) // check that none of the nodes gets deleted @@ -1507,7 +1581,7 @@ func (s *PluginSuite) TestPruneAttestedExpiredNodes() { }) s.Run("prune expired attested nodes", func() { - err := s.ds.PruneAttestedExpiredNodes(ctx, now.Add(-time.Minute), false) + err := s.ds.PruneAttestedExpiredNodes(ctx, now.Add(-time.Minute), false, 0) s.Require().NoError(err) // check that the unexpired node is present @@ -1536,7 +1610,7 @@ func (s *PluginSuite) TestPruneAttestedExpiredNodes() { }) s.Run("prune expired attested nodes including non-reattestable nodes", func() { - err := s.ds.PruneAttestedExpiredNodes(ctx, now.Add(-time.Minute), true) + err := s.ds.PruneAttestedExpiredNodes(ctx, now.Add(-time.Minute), true, 0) s.Require().NoError(err) // check that the valid node is still present @@ -1560,6 +1634,46 @@ func (s *PluginSuite) TestPruneAttestedExpiredNodes() { }) } +func (s *PluginSuite) TestPruneAttestedExpiredNodesBatchSize() { + clk := clock.NewMock(s.T()) + now := clk.Now() + + const total = 5 + + for i := range total { + id := fmt.Sprintf("spiffe://example.org/expired-%d", i) + _, err := s.ds.CreateAttestedNode(ctx, &common.AttestedNode{ + SpiffeId: id, + AttestationDataType: "aws-tag", + CertSerialNumber: "badcafe", + CanReattest: true, + CertNotAfter: now.Add(-time.Hour).Unix(), + }) + s.Require().NoError(err) + } + + countNodes := func() int { + resp, err := s.ds.ListAttestedNodes(ctx, &datastore.ListAttestedNodesRequest{}) + s.Require().NoError(err) + return len(resp.Nodes) + } + s.Require().Equal(total, countNodes()) + + // A batch size smaller than the backlog only prunes up to batchSize per call. + err := s.ds.PruneAttestedExpiredNodes(ctx, now.Add(-time.Minute), false, 2) + s.Require().NoError(err) + s.Require().Equal(total-2, countNodes()) + + err = s.ds.PruneAttestedExpiredNodes(ctx, now.Add(-time.Minute), false, 2) + s.Require().NoError(err) + s.Require().Equal(total-4, countNodes()) + + // A final call drains the remainder. + err = s.ds.PruneAttestedExpiredNodes(ctx, now.Add(-time.Minute), false, 2) + s.Require().NoError(err) + s.Require().Equal(0, countNodes()) +} + func (s *PluginSuite) TestDeleteAttestedNode() { entryFoo := &common.AttestedNode{ SpiffeId: "foo", @@ -1633,6 +1747,245 @@ func (s *PluginSuite) TestDeleteAttestedNode() { }) } +// lastRegistrationEntryEventID returns the highest existing entry event ID, or 0 +// if none exist. Used by cascade tests to scope GreaterThanEventID assertions to +// events created during the test. +func (s *PluginSuite) lastRegistrationEntryEventID() uint { + resp, err := s.ds.ListRegistrationEntryEvents(ctx, &datastore.ListRegistrationEntryEventsRequest{}) + s.Require().NoError(err) + if len(resp.Events) == 0 { + return 0 + } + return resp.Events[len(resp.Events)-1].EventID +} + +func (s *PluginSuite) TestDeleteAttestedNodeCascadesEntries() { + nodeSpiffeID := "spiffe://example.org/spire/agent/join_token/cascade-delete-test" + + _, err := s.ds.CreateAttestedNode(ctx, &common.AttestedNode{ + SpiffeId: nodeSpiffeID, + AttestationDataType: "join_token", + CertSerialNumber: "badcafe", + CertNotAfter: time.Now().Add(time.Hour).Unix(), + }) + s.Require().NoError(err) + + // Create a registration entry whose parent is the attested node's SPIFFE ID. + // This matches the alias row shape that createJoinTokenRegistrationEntry writes + // when CreateJoinToken is called with AgentId. + childEntry, err := s.ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + ParentId: nodeSpiffeID, + SpiffeId: "spiffe://example.org/workload", + Selectors: []*common.Selector{ + {Type: "spiffe_id", Value: nodeSpiffeID}, + }, + }) + s.Require().NoError(err) + + lastEventID := s.lastRegistrationEntryEventID() + + // Deleting the attested node cascades to the child entry. + _, err = s.ds.DeleteAttestedNode(ctx, nodeSpiffeID) + s.Require().NoError(err) + + attestedNode, err := s.ds.FetchAttestedNode(ctx, nodeSpiffeID) + s.Require().NoError(err) + s.Nil(attestedNode) + + fetched, err := s.ds.FetchRegistrationEntry(ctx, childEntry.EntryId) + s.Require().NoError(err) + s.Nil(fetched) + + // A new registration entry event was emitted for the cascaded delete. + resp, err := s.ds.ListRegistrationEntryEvents(ctx, &datastore.ListRegistrationEntryEventsRequest{ + GreaterThanEventID: lastEventID, + }) + s.Require().NoError(err) + s.Require().Len(resp.Events, 1) + s.Equal(childEntry.EntryId, resp.Events[0].EntryID) +} + +func (s *PluginSuite) TestDeleteAttestedNodeJoinTokenPreservesNonAliasChildEntries() { + nodeSpiffeID := "spiffe://example.org/spire/agent/join_token/cascade-shape-test" + + _, err := s.ds.CreateAttestedNode(ctx, &common.AttestedNode{ + SpiffeId: nodeSpiffeID, + AttestationDataType: "join_token", + CertSerialNumber: "badcafe", + CertNotAfter: time.Now().Add(time.Hour).Unix(), + }) + s.Require().NoError(err) + + // Alias-shaped child: the single "spiffe_id" selector whose value matches the + // parent SVID is exactly what createJoinTokenRegistrationEntry writes. Cascade + // must remove it. + aliasChild, err := s.ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + ParentId: nodeSpiffeID, + SpiffeId: "spiffe://example.org/workload-alias", + Selectors: []*common.Selector{{Type: "spiffe_id", Value: nodeSpiffeID}}, + }) + s.Require().NoError(err) + + // User-managed entry parented on the same node but with a non-alias selector + // shape. Cascade must preserve it — these are real workload entries that + // SPIRE never creates implicitly. + workloadChild, err := s.ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + ParentId: nodeSpiffeID, + SpiffeId: "spiffe://example.org/workload-real", + Selectors: []*common.Selector{{Type: "unix", Value: "uid:1000"}}, + }) + s.Require().NoError(err) + + // Attach node selectors so the cascade and existing node-selector delete run + // in the same transaction without interfering. + err = s.ds.SetNodeSelectors(ctx, nodeSpiffeID, []*common.Selector{ + {Type: "ATTEST", Value: "VALUE"}, + }) + s.Require().NoError(err) + + lastEventID := s.lastRegistrationEntryEventID() + + _, err = s.ds.DeleteAttestedNode(ctx, nodeSpiffeID) + s.Require().NoError(err) + + // Alias child is gone; user-managed workload child survives. + fetchedAlias, err := s.ds.FetchRegistrationEntry(ctx, aliasChild.EntryId) + s.Require().NoError(err) + s.Nil(fetchedAlias) + + fetchedWorkload, err := s.ds.FetchRegistrationEntry(ctx, workloadChild.EntryId) + s.Require().NoError(err) + s.Require().NotNil(fetchedWorkload) + s.Equal(workloadChild.EntryId, fetchedWorkload.EntryId) + + // Node selectors are cleared (pre-existing behavior, verified alongside cascade). + nodeSelectors, err := s.ds.GetNodeSelectors(ctx, nodeSpiffeID, datastore.RequireCurrent) + s.Require().NoError(err) + s.Nil(nodeSelectors) + + // Exactly one cascade event was emitted, for the alias child only. + resp, err := s.ds.ListRegistrationEntryEvents(ctx, &datastore.ListRegistrationEntryEventsRequest{ + GreaterThanEventID: lastEventID, + }) + s.Require().NoError(err) + s.Require().Len(resp.Events, 1) + s.Equal(aliasChild.EntryId, resp.Events[0].EntryID) +} + +func (s *PluginSuite) TestPruneAttestedExpiredNodesCascadesEntries() { + now := time.Now() + + expiredNodeID := "spiffe://example.org/spire/agent/join_token/cascade-prune-expired" + validNodeID := "spiffe://example.org/spire/agent/join_token/cascade-prune-valid" + + _, err := s.ds.CreateAttestedNode(ctx, &common.AttestedNode{ + SpiffeId: expiredNodeID, + AttestationDataType: "join_token", + CertSerialNumber: "badcafe", + CanReattest: true, + CertNotAfter: now.Add(-time.Hour).Unix(), + }) + s.Require().NoError(err) + _, err = s.ds.CreateAttestedNode(ctx, &common.AttestedNode{ + SpiffeId: validNodeID, + AttestationDataType: "join_token", + CertSerialNumber: "badcafe", + CanReattest: true, + CertNotAfter: now.Add(time.Hour).Unix(), + }) + s.Require().NoError(err) + + expiredChild, err := s.ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + ParentId: expiredNodeID, + SpiffeId: "spiffe://example.org/workload-a", + Selectors: []*common.Selector{ + {Type: "spiffe_id", Value: expiredNodeID}, + }, + }) + s.Require().NoError(err) + validChild, err := s.ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + ParentId: validNodeID, + SpiffeId: "spiffe://example.org/workload-b", + Selectors: []*common.Selector{ + {Type: "spiffe_id", Value: validNodeID}, + }, + }) + s.Require().NoError(err) + + lastEventID := s.lastRegistrationEntryEventID() + + err = s.ds.PruneAttestedExpiredNodes(ctx, now.Add(-time.Minute), false, 0) + s.Require().NoError(err) + + // Expired node and its child entry are gone. + expiredNode, err := s.ds.FetchAttestedNode(ctx, expiredNodeID) + s.Require().NoError(err) + s.Nil(expiredNode) + fetchedExpiredChild, err := s.ds.FetchRegistrationEntry(ctx, expiredChild.EntryId) + s.Require().NoError(err) + s.Nil(fetchedExpiredChild) + + // Valid node and its child entry are preserved. + valid, err := s.ds.FetchAttestedNode(ctx, validNodeID) + s.Require().NoError(err) + s.NotNil(valid) + fetchedValidChild, err := s.ds.FetchRegistrationEntry(ctx, validChild.EntryId) + s.Require().NoError(err) + s.NotNil(fetchedValidChild) + + // Exactly one new registration entry event was emitted, for the cascaded delete. + resp, err := s.ds.ListRegistrationEntryEvents(ctx, &datastore.ListRegistrationEntryEventsRequest{ + GreaterThanEventID: lastEventID, + }) + s.Require().NoError(err) + s.Require().Len(resp.Events, 1) + s.Equal(expiredChild.EntryId, resp.Events[0].EntryID) +} + +func (s *PluginSuite) TestDeleteAttestedNodeNonJoinTokenDoesNotCascade() { + nodeSpiffeID := "spiffe://example.org/spire/agent/aws_iid/123/i-abcdef" + + _, err := s.ds.CreateAttestedNode(ctx, &common.AttestedNode{ + SpiffeId: nodeSpiffeID, + AttestationDataType: "aws_iid", + CertSerialNumber: "badcafe", + CertNotAfter: time.Now().Add(time.Hour).Unix(), + }) + s.Require().NoError(err) + + // Alias-shaped child entry parented on a non-join-token node. Even though the + // selector shape matches the auto-alias pattern, the DataType filter must + // still skip cascade — only join_token-attested nodes have auto-alias entries. + childEntry, err := s.ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + ParentId: nodeSpiffeID, + SpiffeId: "spiffe://example.org/workload", + Selectors: []*common.Selector{ + {Type: "spiffe_id", Value: nodeSpiffeID}, + }, + }) + s.Require().NoError(err) + + lastEventID := s.lastRegistrationEntryEventID() + + _, err = s.ds.DeleteAttestedNode(ctx, nodeSpiffeID) + s.Require().NoError(err) + + attestedNode, err := s.ds.FetchAttestedNode(ctx, nodeSpiffeID) + s.Require().NoError(err) + s.Nil(attestedNode) + + fetched, err := s.ds.FetchRegistrationEntry(ctx, childEntry.EntryId) + s.Require().NoError(err) + s.Require().NotNil(fetched) + s.Equal(childEntry.EntryId, fetched.EntryId) + + resp, err := s.ds.ListRegistrationEntryEvents(ctx, &datastore.ListRegistrationEntryEventsRequest{ + GreaterThanEventID: lastEventID, + }) + s.Require().NoError(err) + s.Empty(resp.Events) +} + func (s *PluginSuite) TestListAttestedNodeEvents() { var expectedEvents []datastore.AttestedNodeEvent diff --git a/pkg/server/endpoints/authorized_entryfetcher.go b/pkg/server/endpoints/authorized_entryfetcher.go index 1a3b1f25b0..7005f34b83 100644 --- a/pkg/server/endpoints/authorized_entryfetcher.go +++ b/pkg/server/endpoints/authorized_entryfetcher.go @@ -43,6 +43,8 @@ type AuthorizedEntryFetcherEvents struct { type eventsBasedCache interface { updateCache(ctx context.Context) error + loadCache(ctx context.Context, cache *authorizedentries.Cache) error + swapCache(cache *authorizedentries.Cache) } func NewAuthorizedEntryFetcherEvents(ctx context.Context, trustDomain string, c AuthorizedEntryFetcherEventsConfig) (*AuthorizedEntryFetcherEvents, error) { @@ -91,7 +93,7 @@ func (a *AuthorizedEntryFetcherEvents) RunUpdateCacheTask(ctx context.Context) e return ctx.Err() case <-cacheReloadTicker.C: if fullCacheReload { - if err := a.buildCache(ctx); err != nil { + if err := a.reloadCache(ctx); err != nil { a.c.log.WithError(err).Error("Failed to full refresh entry cache") continue } @@ -140,6 +142,27 @@ func (a *AuthorizedEntryFetcherEvents) updateCache(ctx context.Context) error { return errors.Join(updateRegistrationEntriesCacheErr, updateAttestedNodesCacheErr) } +func (a *AuthorizedEntryFetcherEvents) reloadCache(ctx context.Context) error { + cache := authorizedentries.NewCache(a.c.clk, a.trustDomain) + + if err := a.registrationEntries.loadCache(ctx, cache); err != nil { + return err + } + + if err := a.attestedNodes.loadCache(ctx, cache); err != nil { + return err + } + + a.registrationEntries.swapCache(cache) + a.attestedNodes.swapCache(cache) + + a.mu.Lock() + a.cache = cache + a.mu.Unlock() + + return nil +} + func (a *AuthorizedEntryFetcherEvents) buildCache(ctx context.Context) error { cache := authorizedentries.NewCache(a.c.clk, a.trustDomain) @@ -148,7 +171,7 @@ func (a *AuthorizedEntryFetcherEvents) buildCache(ctx context.Context) error { return err } - attestedNodes, err := buildAttestedNodesCache(ctx, a.c.log, a.c.metrics, a.c.ds, a.c.clk, cache, a.c.nodeCache, a.c.cacheReloadInterval, a.c.eventTimeout) + attestedNodes, err := buildAttestedNodesCache(ctx, a.c.log, a.c.metrics, a.c.ds, a.c.clk, cache, a.c.nodeCache, pageSize, a.c.cacheReloadInterval, a.c.eventTimeout) if err != nil { return err } diff --git a/pkg/server/endpoints/authorized_entryfetcher_attested_nodes.go b/pkg/server/endpoints/authorized_entryfetcher_attested_nodes.go index d3b8e0efdc..037129bf82 100644 --- a/pkg/server/endpoints/authorized_entryfetcher_attested_nodes.go +++ b/pkg/server/endpoints/authorized_entryfetcher_attested_nodes.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "maps" + "slices" "time" "github.com/andres-erbsen/clock" @@ -35,6 +37,7 @@ type attestedNodes struct { eventTracker *eventTracker eventTimeout time.Duration + pageSize int32 fetchNodes map[string]struct{} @@ -135,7 +138,7 @@ func (a *attestedNodes) scanForNewEvents(ctx context.Context) error { return nil } -func (a *attestedNodes) loadCache(ctx context.Context) error { +func (a *attestedNodes) loadCache(ctx context.Context, cache *authorizedentries.Cache) error { // TODO: determine if this needs paging nodesResp, err := a.ds.ListAttestedNodes(ctx, &datastore.ListAttestedNodesRequest{ FetchSelectors: true, @@ -149,7 +152,7 @@ func (a *attestedNodes) loadCache(ctx context.Context) error { if agentExpiresAt.Before(a.clk.Now()) { continue } - a.cache.UpdateAgent(node.SpiffeId, agentExpiresAt, api.ProtoFromSelectors(node.Selectors)) + cache.UpdateAgent(node.SpiffeId, agentExpiresAt, api.ProtoFromSelectors(node.Selectors)) a.nodeCache.UpdateAttestedNode(node) } @@ -158,7 +161,11 @@ func (a *attestedNodes) loadCache(ctx context.Context) error { // buildAttestedNodesCache fetches all attested nodes and adds the unexpired ones to the cache. // It runs once at startup. -func buildAttestedNodesCache(ctx context.Context, log logrus.FieldLogger, metrics telemetry.Metrics, ds datastore.DataStore, clk clock.Clock, cache *authorizedentries.Cache, nodeCache *nodecache.Cache, cacheReloadInterval, eventTimeout time.Duration) (*attestedNodes, error) { +func buildAttestedNodesCache(ctx context.Context, log logrus.FieldLogger, metrics telemetry.Metrics, ds datastore.DataStore, clk clock.Clock, cache *authorizedentries.Cache, nodeCache *nodecache.Cache, pageSize int32, cacheReloadInterval, eventTimeout time.Duration) (*attestedNodes, error) { + if pageSize <= 0 { + return nil, fmt.Errorf("page size must be positive, got %d", pageSize) + } + pollPeriods := PollPeriods(cacheReloadInterval, eventTimeout) attestedNodes := &attestedNodes{ @@ -169,6 +176,7 @@ func buildAttestedNodesCache(ctx context.Context, log logrus.FieldLogger, metric log: log, metrics: metrics, eventTimeout: eventTimeout, + pageSize: pageSize, eventsBeforeFirst: make(map[uint]struct{}), fetchNodes: make(map[string]struct{}), @@ -187,7 +195,7 @@ func buildAttestedNodesCache(ctx context.Context, log logrus.FieldLogger, metric return nil, err } - if err := attestedNodes.loadCache(ctx); err != nil { + if err := attestedNodes.loadCache(ctx, cache); err != nil { return nil, err } @@ -211,34 +219,44 @@ func (a *attestedNodes) updateCache(ctx context.Context) error { } func (a *attestedNodes) updateCachedNodes(ctx context.Context) error { - for spiffeId := range a.fetchNodes { - node, err := a.ds.FetchAttestedNode(ctx, spiffeId) + spiffeIds := slices.Collect(maps.Keys(a.fetchNodes)) + for pageStart := 0; pageStart < len(spiffeIds); pageStart += int(a.pageSize) { + fetchNodes := a.fetchNodesPage(spiffeIds, pageStart) + nodes, err := a.ds.FetchAttestedNodes(ctx, fetchNodes) if err != nil { - continue + return err } - // Node was deleted - if node == nil { - a.nodeCache.RemoveAttestedNode(spiffeId) - a.cache.RemoveAgent(spiffeId) - delete(a.fetchNodes, spiffeId) - continue - } + for _, spiffeId := range fetchNodes { + node, ok := nodes[spiffeId] + // Node was deleted (absent from the response, or explicitly nil) + if !ok || node == nil { + a.nodeCache.RemoveAttestedNode(spiffeId) + a.cache.RemoveAgent(spiffeId) + delete(a.fetchNodes, spiffeId) + continue + } - selectors, err := a.ds.GetNodeSelectors(ctx, spiffeId, datastore.RequireCurrent) - if err != nil { - continue + agentExpiresAt := time.Unix(node.CertNotAfter, 0) + a.cache.UpdateAgent(node.SpiffeId, agentExpiresAt, api.ProtoFromSelectors(node.Selectors)) + a.nodeCache.UpdateAttestedNode(node) + delete(a.fetchNodes, spiffeId) } - node.Selectors = selectors - - agentExpiresAt := time.Unix(node.CertNotAfter, 0) - a.cache.UpdateAgent(node.SpiffeId, agentExpiresAt, api.ProtoFromSelectors(node.Selectors)) - a.nodeCache.UpdateAttestedNode(node) - delete(a.fetchNodes, spiffeId) } return nil } +// fetchNodesPage gets the range for the page starting at pageStart +func (a *attestedNodes) fetchNodesPage(spiffeIds []string, pageStart int) []string { + pageEnd := min(len(spiffeIds), pageStart+int(a.pageSize)) + return spiffeIds[pageStart:pageEnd] +} + +func (a *attestedNodes) swapCache(cache *authorizedentries.Cache) { + a.cache = cache + a.fetchNodes = make(map[string]struct{}) +} + func (a *attestedNodes) emitMetrics() { if a.skippedNodeEvents != a.eventTracker.EventCount() { a.skippedNodeEvents = a.eventTracker.EventCount() diff --git a/pkg/server/endpoints/authorized_entryfetcher_attested_nodes_test.go b/pkg/server/endpoints/authorized_entryfetcher_attested_nodes_test.go index ec1b34054a..2a05ef3877 100644 --- a/pkg/server/endpoints/authorized_entryfetcher_attested_nodes_test.go +++ b/pkg/server/endpoints/authorized_entryfetcher_attested_nodes_test.go @@ -78,6 +78,13 @@ func TestLoadNodeCache(t *testing.T) { }, expectedError: "any error, doesn't matter", }, + { + name: "loading with a non-positive page size raises an error", + setup: &nodeScenarioSetup{ + pageSize: -1, + }, + expectedError: "page size must be positive, got -1", + }, { name: "initial load loads nothing", }, @@ -1432,6 +1439,39 @@ func TestUpdateAttestedNodesCache(t *testing.T) { expectedAuthorizedEntries: []string{}, }, + { + name: "empty cache, fetch five nodes spanning multiple pages, three new and two deletes", + setup: &nodeScenarioSetup{ + pageSize: 2, + }, + createAttestedNodes: []*common.AttestedNode{ + { + SpiffeId: "spiffe://example.org/test_node_1", + CertNotAfter: time.Now().Add(time.Duration(240) * time.Hour).Unix(), + }, + { + SpiffeId: "spiffe://example.org/test_node_3", + CertNotAfter: time.Now().Add(time.Duration(240) * time.Hour).Unix(), + }, + { + SpiffeId: "spiffe://example.org/test_node_5", + CertNotAfter: time.Now().Add(time.Duration(240) * time.Hour).Unix(), + }, + }, + fetchNodes: []string{ + "spiffe://example.org/test_node_1", + "spiffe://example.org/test_node_2", + "spiffe://example.org/test_node_3", + "spiffe://example.org/test_node_4", + "spiffe://example.org/test_node_5", + }, + + expectedAuthorizedEntries: []string{ + "spiffe://example.org/test_node_1", + "spiffe://example.org/test_node_3", + "spiffe://example.org/test_node_5", + }, + }, } { t.Run(tt.name, func(t *testing.T) { scenario := NewNodeScenario(t, tt.setup) @@ -1472,19 +1512,21 @@ func TestUpdateAttestedNodesCache(t *testing.T) { // utility functions type scenario struct { - ctx context.Context - log *logrus.Logger - hook *test.Hook - clk *clock.Mock - cache *authorizedentries.Cache - metrics *fakemetrics.FakeMetrics - ds *fakedatastore.DataStore + ctx context.Context + log *logrus.Logger + hook *test.Hook + clk *clock.Mock + cache *authorizedentries.Cache + metrics *fakemetrics.FakeMetrics + ds *fakedatastore.DataStore + pageSize int32 } type nodeScenarioSetup struct { attestedNodes []*common.AttestedNode attestedNodeEvents []*datastore.AttestedNodeEvent err error + pageSize int32 } func NewNodeScenario(t *testing.T, setup *nodeScenarioSetup) *scenario { @@ -1500,6 +1542,10 @@ func NewNodeScenario(t *testing.T, setup *nodeScenarioSetup) *scenario { if setup == nil { setup = &nodeScenarioSetup{} } + pageSize := setup.pageSize + if pageSize == 0 { + pageSize = 1024 + } var err error // initialize the database @@ -1522,13 +1568,14 @@ func NewNodeScenario(t *testing.T, setup *nodeScenarioSetup) *scenario { } return &scenario{ - ctx: ctx, - log: log, - hook: hook, - clk: clk, - cache: cache, - metrics: metrics, - ds: ds, + ctx: ctx, + log: log, + hook: hook, + clk: clk, + cache: cache, + metrics: metrics, + ds: ds, + pageSize: pageSize, } } @@ -1538,7 +1585,7 @@ func (s *scenario) buildAttestedNodesCache() (*attestedNodes, error) { return nil, err } - attestedNodes, err := buildAttestedNodesCache(s.ctx, s.log, s.metrics, s.ds, s.clk, s.cache, nodeCache, defaultCacheReloadInterval, defaultEventTimeout) + attestedNodes, err := buildAttestedNodesCache(s.ctx, s.log, s.metrics, s.ds, s.clk, s.cache, nodeCache, s.pageSize, defaultCacheReloadInterval, defaultEventTimeout) if attestedNodes != nil { // clear out the fetches for node := range attestedNodes.fetchNodes { diff --git a/pkg/server/endpoints/authorized_entryfetcher_registration_entries.go b/pkg/server/endpoints/authorized_entryfetcher_registration_entries.go index 078bec05c6..90942ecf69 100644 --- a/pkg/server/endpoints/authorized_entryfetcher_registration_entries.go +++ b/pkg/server/endpoints/authorized_entryfetcher_registration_entries.go @@ -136,7 +136,7 @@ func (a *registrationEntries) scanForNewEvents(ctx context.Context) error { return nil } -func (a *registrationEntries) loadCache(ctx context.Context) error { +func (a *registrationEntries) loadCache(ctx context.Context, cache *authorizedentries.Cache) error { // Build the cache var token string for { @@ -162,7 +162,7 @@ func (a *registrationEntries) loadCache(ctx context.Context) error { } for _, entry := range entries { - a.cache.UpdateEntry(entry) + cache.UpdateEntry(entry) } } return nil @@ -198,7 +198,7 @@ func buildRegistrationEntriesCache(ctx context.Context, log logrus.FieldLogger, return nil, err } - if err := registrationEntries.loadCache(ctx); err != nil { + if err := registrationEntries.loadCache(ctx, cache); err != nil { return nil, err } @@ -261,6 +261,11 @@ func (a *registrationEntries) fetchEntriesPage(entryIds []string, pageStart int) return entryIds[pageStart:pageEnd] } +func (a *registrationEntries) swapCache(cache *authorizedentries.Cache) { + a.cache = cache + a.fetchEntries = make(map[string]struct{}) +} + func (a *registrationEntries) emitMetrics() { if a.skippedEntryEvents != a.eventTracker.EventCount() { a.skippedEntryEvents = a.eventTracker.EventCount() diff --git a/pkg/server/endpoints/authorized_entryfetcher_test.go b/pkg/server/endpoints/authorized_entryfetcher_test.go index 552ed16547..3286616d9a 100644 --- a/pkg/server/endpoints/authorized_entryfetcher_test.go +++ b/pkg/server/endpoints/authorized_entryfetcher_test.go @@ -3,6 +3,7 @@ package endpoints import ( "context" "errors" + "sync" "testing" "time" @@ -214,7 +215,7 @@ func TestBuildCacheSavesSkippedEvents(t *testing.T) { require.NoError(t, err) require.NotNil(t, registrationEntries) - attestedNodes, err := buildAttestedNodesCache(ctx, log, metrics, ds, clk, cache, nodeCache, defaultCacheReloadInterval, defaultEventTimeout) + attestedNodes, err := buildAttestedNodesCache(ctx, log, metrics, ds, clk, cache, nodeCache, pageSize, defaultCacheReloadInterval, defaultEventTimeout) require.NoError(t, err) require.NotNil(t, attestedNodes) @@ -1073,6 +1074,359 @@ func TestFullCacheReloadRecoversFromSkippedAttestedNodeEvents(t *testing.T) { compareEntries(t, entries, entry) } +func TestReloadCacheRecoversFromSkippedRegistrationEntryEvents(t *testing.T) { + ctx := context.Background() + log, _ := test.NewNullLogger() + clk := clock.NewMock(t) + ds := fakedatastore.New(t) + metrics := fakemetrics.New() + + nodeCache, err := nodecache.New(ctx, log, ds, clk, false, true) + require.Nil(t, err) + + ef, err := NewAuthorizedEntryFetcherEvents(ctx, "example.org", AuthorizedEntryFetcherEventsConfig{ + log: log, + metrics: metrics, + clk: clk, + ds: ds, + nodeCache: nodeCache, + cacheReloadInterval: defaultCacheReloadInterval, + fullCacheReloadInterval: defaultFullCacheReloadInterval, + pruneEventsOlderThan: defaultPruneEventsOlderThan, + eventTimeout: defaultEventTimeout, + }) + require.NoError(t, err) + require.NotNil(t, ef) + + agentID := spiffeid.RequireFromString("spiffe://example.org/myagent") + + // Ensure no entries are in there to start + entries, err := ef.FetchAuthorizedEntries(ctx, agentID) + require.NoError(t, err) + require.Empty(t, entries) + + // Create Initial Registration Entry + entry1, err := ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + SpiffeId: "spiffe://example.org/workload", + ParentId: agentID.String(), + Selectors: []*common.Selector{ + { + Type: "workload", + Value: "one", + }, + }, + }) + require.NoError(t, err) + + // Ensure it gets added to cache + err = ef.updateCache(ctx) + require.NoError(t, err) + + entries, err = ef.FetchAuthorizedEntries(ctx, agentID) + require.NoError(t, err) + compareEntries(t, entries, entry1) + + // Create Second entry + entry2, err := ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + SpiffeId: "spiffe://example.org/workload2", + ParentId: agentID.String(), + Selectors: []*common.Selector{ + { + Type: "workload", + Value: "two", + }, + }, + }) + require.NoError(t, err) + + // Delete the event + err = ds.DeleteRegistrationEntryEventForTesting(ctx, 2) + require.NoError(t, err) + + // Check second entry is not added to cache + err = ef.updateCache(ctx) + require.NoError(t, err) + + entries, err = ef.FetchAuthorizedEntries(ctx, agentID) + require.NoError(t, err) + compareEntries(t, entries, entry1) + + // Reload the cache (not full rebuild) + err = ef.reloadCache(ctx) + require.NoError(t, err) + + // Should be 2 entries now + entries, err = ef.FetchAuthorizedEntries(ctx, agentID) + require.NoError(t, err) + compareEntries(t, entries, entry1, entry2) +} + +func TestReloadCacheRecoversFromSkippedAttestedNodeEvents(t *testing.T) { + ctx := context.Background() + log, _ := test.NewNullLogger() + clk := clock.NewMock(t) + ds := fakedatastore.New(t) + metrics := fakemetrics.New() + + nodeCache, err := nodecache.New(ctx, log, ds, clk, false, true) + require.Nil(t, err) + + ef, err := NewAuthorizedEntryFetcherEvents(ctx, "example.org", AuthorizedEntryFetcherEventsConfig{ + log: log, + metrics: metrics, + clk: clk, + ds: ds, + nodeCache: nodeCache, + cacheReloadInterval: defaultCacheReloadInterval, + fullCacheReloadInterval: defaultFullCacheReloadInterval, + pruneEventsOlderThan: defaultPruneEventsOlderThan, + eventTimeout: defaultEventTimeout, + }) + require.NoError(t, err) + require.NotNil(t, ef) + + agent1 := spiffeid.RequireFromString("spiffe://example.org/myagent1") + agent2 := spiffeid.RequireFromString("spiffe://example.org/myagent2") + + // Ensure no entries are in there to start + entries, err := ef.FetchAuthorizedEntries(ctx, agent2) + require.NoError(t, err) + require.Empty(t, entries) + + // Create node alias for agent 2 + alias, err := ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + SpiffeId: "spiffe://example.org/alias", + ParentId: "spiffe://example.org/spire/server", + Selectors: []*common.Selector{ + { + Type: "test", + Value: "alias", + }, + }, + }) + assert.NoError(t, err) + + // Create a registration entry parented to the alias + entry, err := ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + SpiffeId: "spiffe://example.org/viaalias", + ParentId: alias.SpiffeId, + Selectors: []*common.Selector{ + { + Type: "workload", + Value: "two", + }, + }, + }) + assert.NoError(t, err) + + // Create both Attested Nodes + _, err = ds.CreateAttestedNode(ctx, &common.AttestedNode{ + SpiffeId: agent1.String(), + CertNotAfter: time.Now().Add(5 * time.Hour).Unix(), + }) + require.NoError(t, err) + + _, err = ds.CreateAttestedNode(ctx, &common.AttestedNode{ + SpiffeId: agent2.String(), + CertNotAfter: time.Now().Add(5 * time.Hour).Unix(), + }) + require.NoError(t, err) + + // Create selectors for agent 2 + err = ds.SetNodeSelectors(ctx, agent2.String(), []*common.Selector{ + { + Type: "test", + Value: "alias", + }, + { + Type: "test", + Value: "cluster2", + }, + }) + assert.NoError(t, err) + + // Create selectors for agent 1 + err = ds.SetNodeSelectors(ctx, agent1.String(), []*common.Selector{ + { + Type: "test", + Value: "cluster1", + }, + }) + assert.NoError(t, err) + + // Delete the events for agent 2 to simulate missed events + err = ds.DeleteAttestedNodeEventForTesting(ctx, 2) + require.NoError(t, err) + err = ds.DeleteAttestedNodeEventForTesting(ctx, 3) + require.NoError(t, err) + + // Should not be in cache yet + err = ef.updateCache(ctx) + require.NoError(t, err) + + entries, err = ef.FetchAuthorizedEntries(ctx, agent2) + require.NoError(t, err) + require.Len(t, entries, 0) + + // Reload cache (not full rebuild) + err = ef.reloadCache(ctx) + require.NoError(t, err) + + // Should be recovered + entries, err = ef.FetchAuthorizedEntries(ctx, agent2) + require.NoError(t, err) + compareEntries(t, entries, entry) +} + +func TestReloadCachePreservesEventState(t *testing.T) { + ctx := context.Background() + log, _ := test.NewNullLogger() + clk := clock.NewMock(t) + ds := fakedatastore.New(t) + countingDS := &nodeCallCountingDataStore{DataStore: ds} + metrics := fakemetrics.New() + + nodeCache, err := nodecache.New(ctx, log, ds, clk, false, true) + require.Nil(t, err) + + ef, err := NewAuthorizedEntryFetcherEvents(ctx, "example.org", AuthorizedEntryFetcherEventsConfig{ + log: log, + metrics: metrics, + clk: clk, + ds: countingDS, + nodeCache: nodeCache, + cacheReloadInterval: defaultCacheReloadInterval, + fullCacheReloadInterval: defaultFullCacheReloadInterval, + pruneEventsOlderThan: defaultPruneEventsOlderThan, + eventTimeout: defaultEventTimeout, + }) + require.NoError(t, err) + require.NotNil(t, ef) + + agentID := spiffeid.RequireFromString("spiffe://example.org/myagent") + + // Create a registration entry and process it to establish event state + entry1, err := ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + SpiffeId: "spiffe://example.org/workload", + ParentId: agentID.String(), + Selectors: []*common.Selector{ + { + Type: "workload", + Value: "one", + }, + }, + }) + require.NoError(t, err) + + // Create attested node + _, err = ds.CreateAttestedNode(ctx, &common.AttestedNode{ + SpiffeId: agentID.String(), + CertNotAfter: time.Now().Add(5 * time.Hour).Unix(), + }) + require.NoError(t, err) + + err = ds.SetNodeSelectors(ctx, agentID.String(), []*common.Selector{ + { + Type: "workload", + Value: "one", + }, + }) + require.NoError(t, err) + + // Process events to establish lastEvent watermarks + err = ef.updateCache(ctx) + require.NoError(t, err) + + entries, err := ef.FetchAuthorizedEntries(ctx, agentID) + require.NoError(t, err) + compareEntries(t, entries, entry1) + + // Capture event state before reload + regEntries := ef.registrationEntries.(*registrationEntries) + nodeEntries := ef.attestedNodes.(*attestedNodes) + lastRegEvent := regEntries.lastEvent + lastNodeEvent := nodeEntries.lastEvent + firstRegEvent := regEntries.firstEvent + firstNodeEvent := nodeEntries.firstEvent + + require.NotZero(t, lastRegEvent, "should have processed registration entry events") + require.NotZero(t, lastNodeEvent, "should have processed attested node events") + + // Reload cache + err = ef.reloadCache(ctx) + require.NoError(t, err) + + // Verify event state is preserved + require.Equal(t, lastRegEvent, regEntries.lastEvent, "lastEvent should be preserved across reload") + require.Equal(t, lastNodeEvent, nodeEntries.lastEvent, "lastEvent should be preserved across reload") + require.Equal(t, firstRegEvent, regEntries.firstEvent, "firstEvent should be preserved across reload") + require.Equal(t, firstNodeEvent, nodeEntries.firstEvent, "firstEvent should be preserved across reload") + + // Verify fetchNodes/fetchEntries are cleared + require.Empty(t, regEntries.fetchEntries, "fetchEntries should be cleared after reload") + require.Empty(t, nodeEntries.fetchNodes, "fetchNodes should be cleared after reload") + + // Verify cache still has correct data + entries, err = ef.FetchAuthorizedEntries(ctx, agentID) + require.NoError(t, err) + compareEntries(t, entries, entry1) + + // Reset counters and call updateCache with no new events — should not + // trigger individual FetchAttestedNode or GetNodeSelectors calls. + countingDS.fetchAttestedNodeCount = 0 + countingDS.getNodeSelectorsCount = 0 + countingDS.fetchRegistrationEntriesCount = 0 + + err = ef.updateCache(ctx) + require.NoError(t, err) + + require.Zero(t, countingDS.fetchAttestedNodeCount, "should not fetch individual attested nodes after reload") + require.Zero(t, countingDS.getNodeSelectorsCount, "should not fetch individual node selectors after reload") + require.Zero(t, countingDS.fetchRegistrationEntriesCount, "should not fetch individual registration entries after reload") + + // Create a new entry after the reload — should be picked up by event-based update + entry2, err := ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + SpiffeId: "spiffe://example.org/workload2", + ParentId: agentID.String(), + Selectors: []*common.Selector{ + { + Type: "workload", + Value: "two", + }, + }, + }) + require.NoError(t, err) + + err = ef.updateCache(ctx) + require.NoError(t, err) + + entries, err = ef.FetchAuthorizedEntries(ctx, agentID) + require.NoError(t, err) + compareEntries(t, entries, entry1, entry2) +} + +type nodeCallCountingDataStore struct { + datastore.DataStore + fetchAttestedNodeCount int + getNodeSelectorsCount int + fetchRegistrationEntriesCount int +} + +func (d *nodeCallCountingDataStore) FetchAttestedNode(ctx context.Context, spiffeID string) (*common.AttestedNode, error) { + d.fetchAttestedNodeCount++ + return d.DataStore.FetchAttestedNode(ctx, spiffeID) +} + +func (d *nodeCallCountingDataStore) GetNodeSelectors(ctx context.Context, spiffeID string, dataConsistency datastore.DataConsistency) ([]*common.Selector, error) { + d.getNodeSelectorsCount++ + return d.DataStore.GetNodeSelectors(ctx, spiffeID, dataConsistency) +} + +func (d *nodeCallCountingDataStore) FetchRegistrationEntries(ctx context.Context, entryIDs []string) (map[string]*common.RegistrationEntry, error) { + d.fetchRegistrationEntriesCount++ + return d.DataStore.FetchRegistrationEntries(ctx, entryIDs) +} + // AgentsByIDCacheCount func agentsByIDMetric(val float64) fakemetrics.MetricItem { return fakemetrics.MetricItem{ @@ -1136,6 +1490,139 @@ func entriesSkippedEventMetric(val float64) fakemetrics.MetricItem { } } +func TestConcurrentReloadAndFetch(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + log, _ := test.NewNullLogger() + clk := clock.NewMock(t) + ds := fakedatastore.New(t) + metrics := fakemetrics.New() + + nodeCache, err := nodecache.New(ctx, log, ds, clk, false, true) + require.NoError(t, err) + + agentID := spiffeid.RequireFromString("spiffe://example.org/myagent") + + // Create an attested node with selectors + _, err = ds.CreateAttestedNode(ctx, &common.AttestedNode{ + SpiffeId: agentID.String(), + CertNotAfter: time.Now().Add(5 * time.Hour).Unix(), + }) + require.NoError(t, err) + + err = ds.SetNodeSelectors(ctx, agentID.String(), []*common.Selector{ + {Type: "test", Value: "alias"}, + }) + require.NoError(t, err) + + // Create a node alias + _, err = ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + SpiffeId: "spiffe://example.org/alias", + ParentId: "spiffe://example.org/spire/server", + Selectors: []*common.Selector{ + {Type: "test", Value: "alias"}, + }, + }) + require.NoError(t, err) + + // Create registration entries + entry1, err := ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + SpiffeId: "spiffe://example.org/workload1", + ParentId: agentID.String(), + Selectors: []*common.Selector{ + {Type: "workload", Value: "one"}, + }, + }) + require.NoError(t, err) + + entry2, err := ds.CreateRegistrationEntry(ctx, &common.RegistrationEntry{ + SpiffeId: "spiffe://example.org/workload2", + ParentId: agentID.String(), + Selectors: []*common.Selector{ + {Type: "workload", Value: "two"}, + }, + }) + require.NoError(t, err) + + ef, err := NewAuthorizedEntryFetcherEvents(ctx, "example.org", AuthorizedEntryFetcherEventsConfig{ + log: log, + metrics: metrics, + clk: clk, + ds: ds, + nodeCache: nodeCache, + cacheReloadInterval: defaultCacheReloadInterval, + fullCacheReloadInterval: defaultFullCacheReloadInterval, + pruneEventsOlderThan: defaultPruneEventsOlderThan, + eventTimeout: defaultEventTimeout, + }) + require.NoError(t, err) + + // Populate the cache with initial data + err = ef.updateCache(ctx) + require.NoError(t, err) + + entries, err := ef.FetchAuthorizedEntries(ctx, agentID) + require.NoError(t, err) + require.Len(t, entries, 2) + + // Run concurrent readers alongside reloadCache. + // With -race, the race detector will flag any unsynchronized access. + const numReaders = 4 + const numIterations = 50 + const numGoroutines = numReaders*2 + 1 + + var start, done sync.WaitGroup + start.Add(numGoroutines) + + // Readers calling FetchAuthorizedEntries + for range numReaders { + done.Go(func() { + start.Done() + start.Wait() + for range numIterations { + fetched, err := ef.FetchAuthorizedEntries(ctx, agentID) + assert.NoError(t, err) + assert.NotEmpty(t, fetched) + } + }) + } + + // Readers calling LookupAuthorizedEntries + for range numReaders { + done.Go(func() { + start.Done() + start.Wait() + entryIDs := map[string]struct{}{ + entry1.EntryId: {}, + entry2.EntryId: {}, + } + for range numIterations { + looked, err := ef.LookupAuthorizedEntries(ctx, agentID, entryIDs) + assert.NoError(t, err) + assert.NotEmpty(t, looked) + } + }) + } + + // Writer calling reloadCache concurrently + done.Go(func() { + start.Done() + start.Wait() + for range numIterations { + err := ef.reloadCache(ctx) + assert.NoError(t, err) + } + }) + + done.Wait() + + // Verify cache is still correct after all the concurrent activity + entries, err = ef.FetchAuthorizedEntries(ctx, agentID) + require.NoError(t, err) + compareEntries(t, entries, entry1, entry2) +} + func compareEntries(t *testing.T, authorizedEntries []api.ReadOnlyEntry, entries ...*common.RegistrationEntry) { t.Helper() diff --git a/pkg/server/endpoints/bundle/server.go b/pkg/server/endpoints/bundle/server.go index 0ee89553a1..ef867331a2 100644 --- a/pkg/server/endpoints/bundle/server.go +++ b/pkg/server/endpoints/bundle/server.go @@ -11,6 +11,7 @@ import ( "github.com/sirupsen/logrus" "github.com/spiffe/go-spiffe/v2/bundle/spiffebundle" "github.com/spiffe/spire/pkg/common/bundleutil" + "github.com/spiffe/spire/pkg/common/tlspolicy" ) type Getter interface { @@ -33,6 +34,7 @@ type ServerConfig struct { Getter Getter ServerAuth ServerAuth RefreshHint time.Duration + TLSPolicy tlspolicy.Policy // test hooks listen func(network, address string) (net.Listener, error) @@ -63,6 +65,10 @@ func (s *Server) ListenAndServe(ctx context.Context) error { tlsConfig := s.c.ServerAuth.GetTLSConfig() tlsConfig.MinVersion = tls.VersionTLS12 + if err := tlspolicy.ApplyPolicy(tlsConfig, s.c.TLSPolicy); err != nil { + return err + } + server := &http.Server{ Handler: http.HandlerFunc(s.serveHTTP), TLSConfig: tlsConfig, diff --git a/pkg/server/endpoints/config.go b/pkg/server/endpoints/config.go index 0422ace9d5..0fd02d94c7 100644 --- a/pkg/server/endpoints/config.go +++ b/pkg/server/endpoints/config.go @@ -160,6 +160,7 @@ func (c *Config) maybeMakeBundleEndpointServer() (Server, func(context.Context) }), RefreshHint: c.BundleEndpoint.RefreshHint, ServerAuth: serverAuth, + TLSPolicy: c.TLSPolicy, }), certificateReloadTask } diff --git a/pkg/server/node/manager.go b/pkg/server/node/manager.go index 6c0e2536a4..f4add1495e 100644 --- a/pkg/server/node/manager.go +++ b/pkg/server/node/manager.go @@ -31,6 +31,10 @@ type ManagerConfig struct { Clock clock.Clock Interval time.Duration + // BatchSize is the maximum number of expired nodes pruned per cycle. + // A non-positive value falls back to the datastore default. + BatchSize int + PruneArgs } @@ -94,6 +98,6 @@ func (m *Manager) prune(ctx context.Context, expiredBefore time.Time, includeNon counter := telemetry_server.StartNodeManagerPruneAttestedExpiredNodesCall(m.c.Metrics) defer counter.Done(&err) - err = m.c.DataStore.PruneAttestedExpiredNodes(ctx, expiredBefore, includeNonReattestable) + err = m.c.DataStore.PruneAttestedExpiredNodes(ctx, expiredBefore, includeNonReattestable, m.c.BatchSize) return err } diff --git a/pkg/server/plugin/bundlepublisher/awsrolesanywhere/awsrolesanywhere.go b/pkg/server/plugin/bundlepublisher/awsrolesanywhere/awsrolesanywhere.go index 0564b34e32..73d5852d28 100644 --- a/pkg/server/plugin/bundlepublisher/awsrolesanywhere/awsrolesanywhere.go +++ b/pkg/server/plugin/bundlepublisher/awsrolesanywhere/awsrolesanywhere.go @@ -98,12 +98,10 @@ func (p *Plugin) Configure(ctx context.Context, req *configv1.ConfigureRequest) if err != nil { return nil, status.Errorf(codes.Internal, "failed to create client: %v", err) } - p.rolesAnywhereClient = rolesAnywhere - - p.configMtx.Lock() - defer p.configMtx.Unlock() - p.config = newConfig - + // Store the config and client together under one lock so that + // PublishBundle always observes a matching pair, even when Configure + // runs concurrently as a result of a dynamic reconfiguration. + p.setConfig(newConfig, rolesAnywhere) p.setBundle(nil) return &configv1.ConfigureResponse{}, nil } @@ -120,7 +118,7 @@ func (p *Plugin) Validate(ctx context.Context, req *configv1.ValidateRequest) (* // PublishBundle puts the bundle in the Roles Anywhere trust anchor, with // the configured id. func (p *Plugin) PublishBundle(ctx context.Context, req *bundlepublisherv1.PublishBundleRequest) (*bundlepublisherv1.PublishBundleResponse, error) { - config, err := p.getConfig() + config, rolesAnywhereClient, err := p.getConfig() if err != nil { return nil, err } @@ -158,7 +156,7 @@ func (p *Plugin) PublishBundle(ctx context.Context, req *bundlepublisherv1.Publi }, }, } - updateTrustAnchorOutput, err := p.rolesAnywhereClient.UpdateTrustAnchor(ctx, &updateTrustAnchorInput) + updateTrustAnchorOutput, err := rolesAnywhereClient.UpdateTrustAnchor(ctx, &updateTrustAnchorInput) if err != nil { return nil, status.Errorf(codes.Internal, "failed to update trust anchor: %v", err) } @@ -172,21 +170,33 @@ func (p *Plugin) PublishBundle(ctx context.Context, req *bundlepublisherv1.Publi // getBundle gets the latest bundle that the plugin has. func (p *Plugin) getBundle() *types.Bundle { - p.configMtx.RLock() - defer p.configMtx.RUnlock() + p.bundleMtx.RLock() + defer p.bundleMtx.RUnlock() return p.bundle } -// getConfig gets the configuration of the plugin. -func (p *Plugin) getConfig() (*Config, error) { +// getConfig gets the configuration of the plugin along with the client +// created for it. Both are returned together so callers always observe a +// matching pair. +func (p *Plugin) getConfig() (*Config, rolesAnywhere, error) { p.configMtx.RLock() defer p.configMtx.RUnlock() if p.config == nil { - return nil, status.Error(codes.FailedPrecondition, "not configured") + return nil, nil, status.Error(codes.FailedPrecondition, "not configured") } - return p.config, nil + return p.config, p.rolesAnywhereClient, nil +} + +// setConfig sets the configuration for the plugin along with the client +// created for it, updating both atomically under one lock. +func (p *Plugin) setConfig(config *Config, client rolesAnywhere) { + p.configMtx.Lock() + defer p.configMtx.Unlock() + + p.config = config + p.rolesAnywhereClient = client } // setBundle updates the current bundle in the plugin with the provided bundle. diff --git a/pkg/server/plugin/bundlepublisher/awsrolesanywhere/awsrolesanywhere_test.go b/pkg/server/plugin/bundlepublisher/awsrolesanywhere/awsrolesanywhere_test.go index c33d5ffb11..9d2e387416 100644 --- a/pkg/server/plugin/bundlepublisher/awsrolesanywhere/awsrolesanywhere_test.go +++ b/pkg/server/plugin/bundlepublisher/awsrolesanywhere/awsrolesanywhere_test.go @@ -3,7 +3,9 @@ package awsrolesanywhere import ( "context" "crypto/x509" + "encoding/json" "errors" + "sync" "testing" "github.com/aws/aws-sdk-go-v2/aws" @@ -19,6 +21,7 @@ import ( "github.com/spiffe/spire/test/util" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" + "google.golang.org/protobuf/proto" ) func TestConfigure(t *testing.T) { @@ -314,6 +317,82 @@ func TestPublishMultiple(t *testing.T) { require.Error(t, err) } +// TestConcurrentConfigureAndPublish exercises Configure running concurrently +// with PublishBundle, as happens with dynamic reconfiguration. It is meant to +// be run with the race detector to catch unsynchronized access to the client. +func TestConcurrentConfigureAndPublish(t *testing.T) { + config := &Config{ + AccessKeyID: "access-key-id", + SecretAccessKey: "secret-access-key", + Region: "region", + TrustAnchorID: "trust-anchor-id", + } + + newClient := func(awsConfig aws.Config) (rolesAnywhere, error) { + return &fakeClient{ + t: t, + expectTrustAnchorID: aws.String(config.TrustAnchorID), + }, nil + } + p := newPlugin(newClient) + + var err error + plugintest.Load(t, builtin(p), nil, + plugintest.CaptureConfigureError(&err), + plugintest.CoreConfig(catalog.CoreConfig{ + TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), + }), + plugintest.ConfigureJSON(config), + ) + require.NoError(t, err) + + hclConfig, err := json.Marshal(config) + require.NoError(t, err) + configureReq := &configv1.ConfigureRequest{ + CoreConfiguration: &configv1.CoreConfiguration{TrustDomain: "example.org"}, + HclConfiguration: string(hclConfig), + } + + bundle := getTestBundle(t) + + const iterations = 100 + errCh := make(chan error, 2) + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + for range iterations { + if _, cerr := p.Configure(context.Background(), configureReq); cerr != nil { + errCh <- cerr + return + } + } + }() + + go func() { + defer wg.Done() + for i := range iterations { + // Vary the bundle so the dedup check does not short-circuit and + // PublishBundle reaches the client. + b := proto.Clone(bundle).(*types.Bundle) + b.SequenceNumber = uint64(i + 1) + if _, perr := p.PublishBundle(context.Background(), &bundlepublisherv1.PublishBundleRequest{ + Bundle: b, + }); perr != nil { + errCh <- perr + return + } + } + }() + + wg.Wait() + close(errCh) + for e := range errCh { + require.NoError(t, e) + } +} + type fakeClient struct { t *testing.T diff --git a/pkg/server/plugin/bundlepublisher/awss3/awss3.go b/pkg/server/plugin/bundlepublisher/awss3/awss3.go index e3893688dd..41d9ac9f56 100644 --- a/pkg/server/plugin/bundlepublisher/awss3/awss3.go +++ b/pkg/server/plugin/bundlepublisher/awss3/awss3.go @@ -141,7 +141,6 @@ func (p *Plugin) Configure(ctx context.Context, req *configv1.ConfigureRequest) p.log.Warn(note) } - // seems wrong to change plugin s3Client before config change awsCfg, err := newAWSConfig(ctx, newConfig) if err != nil { return nil, status.Errorf(codes.Internal, "failed to create client configuration: %v", err) @@ -150,9 +149,11 @@ func (p *Plugin) Configure(ctx context.Context, req *configv1.ConfigureRequest) if err != nil { return nil, status.Errorf(codes.Internal, "failed to create client: %v", err) } - p.s3Client = s3Client - p.setConfig(newConfig) + // Store the config and client together under one lock so that + // PublishBundle always observes a matching pair, even when Configure + // runs concurrently as a result of a dynamic reconfiguration. + p.setConfig(newConfig, s3Client) p.setBundle(nil) return &configv1.ConfigureResponse{}, nil } @@ -169,7 +170,7 @@ func (p *Plugin) Validate(ctx context.Context, req *configv1.ValidateRequest) (* // PublishBundle puts the bundle in the configured S3 bucket name and // object key. func (p *Plugin) PublishBundle(ctx context.Context, req *bundlepublisherv1.PublishBundleRequest) (*bundlepublisherv1.PublishBundleResponse, error) { - config, err := p.getConfig() + config, s3Client, err := p.getConfig() if err != nil { return nil, err } @@ -195,7 +196,7 @@ func (p *Plugin) PublishBundle(ctx context.Context, req *bundlepublisherv1.Publi return nil, status.Errorf(codes.Internal, "could not format bundle: %v", err.Error()) } - _, err = p.s3Client.PutObject(ctx, &s3.PutObjectInput{ + _, err = s3Client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(config.Bucket), Body: bytes.NewReader(bundleBytes), Key: aws.String(config.ObjectKey), @@ -212,21 +213,23 @@ func (p *Plugin) PublishBundle(ctx context.Context, req *bundlepublisherv1.Publi // getBundle gets the latest bundle that the plugin has. func (p *Plugin) getBundle() *types.Bundle { - p.configMtx.RLock() - defer p.configMtx.RUnlock() + p.bundleMtx.RLock() + defer p.bundleMtx.RUnlock() return p.bundle } -// getConfig gets the configuration of the plugin. -func (p *Plugin) getConfig() (*Config, error) { +// getConfig gets the configuration of the plugin along with the client +// created for it. Both are returned together so callers always observe a +// matching pair. +func (p *Plugin) getConfig() (*Config, simpleStorageService, error) { p.configMtx.RLock() defer p.configMtx.RUnlock() if p.config == nil { - return nil, status.Error(codes.FailedPrecondition, "not configured") + return nil, nil, status.Error(codes.FailedPrecondition, "not configured") } - return p.config, nil + return p.config, p.s3Client, nil } // setBundle updates the current bundle in the plugin with the provided bundle. @@ -237,12 +240,14 @@ func (p *Plugin) setBundle(bundle *types.Bundle) { p.bundle = bundle } -// setConfig sets the configuration for the plugin. -func (p *Plugin) setConfig(config *Config) { +// setConfig sets the configuration for the plugin along with the client +// created for it, updating both atomically under one lock. +func (p *Plugin) setConfig(config *Config, client simpleStorageService) { p.configMtx.Lock() defer p.configMtx.Unlock() p.config = config + p.s3Client = client } // builtin creates a new BundlePublisher built-in plugin. diff --git a/pkg/server/plugin/bundlepublisher/awss3/awss3_test.go b/pkg/server/plugin/bundlepublisher/awss3/awss3_test.go index eba0171b83..45665e5195 100644 --- a/pkg/server/plugin/bundlepublisher/awss3/awss3_test.go +++ b/pkg/server/plugin/bundlepublisher/awss3/awss3_test.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "crypto/x509" + "encoding/json" "errors" "io" + "sync" "testing" "time" @@ -23,6 +25,7 @@ import ( "github.com/spiffe/spire/test/util" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" + "google.golang.org/protobuf/proto" ) func TestConfigure(t *testing.T) { @@ -525,6 +528,85 @@ func TestBundleWithRefreshHintPublishedOnce(t *testing.T) { require.Equal(t, 1, client.putObjectCount) } +// TestConcurrentConfigureAndPublish exercises Configure running concurrently +// with PublishBundle, as happens with dynamic reconfiguration. It is meant to +// be run with the race detector to catch unsynchronized access to the client. +func TestConcurrentConfigureAndPublish(t *testing.T) { + config := &Config{ + AccessKeyID: "access-key-id", + SecretAccessKey: "secret-access-key", + Region: "region", + Bucket: "bucket", + ObjectKey: "object-key", + Format: "spiffe", + } + + newClient := func(awsConfig aws.Config) (simpleStorageService, error) { + return &fakeClient{ + t: t, + expectBucket: aws.String(config.Bucket), + expectKey: aws.String(config.ObjectKey), + }, nil + } + p := newPlugin(newClient) + + var err error + plugintest.Load(t, builtin(p), nil, + plugintest.CaptureConfigureError(&err), + plugintest.CoreConfig(catalog.CoreConfig{ + TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), + }), + plugintest.ConfigureJSON(config), + ) + require.NoError(t, err) + + hclConfig, err := json.Marshal(config) + require.NoError(t, err) + configureReq := &configv1.ConfigureRequest{ + CoreConfiguration: &configv1.CoreConfiguration{TrustDomain: "example.org"}, + HclConfiguration: string(hclConfig), + } + + bundle := getTestBundle(t) + + const iterations = 100 + errCh := make(chan error, 2) + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + for range iterations { + if _, cerr := p.Configure(context.Background(), configureReq); cerr != nil { + errCh <- cerr + return + } + } + }() + + go func() { + defer wg.Done() + for i := range iterations { + // Vary the bundle so the dedup check does not short-circuit and + // PublishBundle reaches the client. + b := proto.Clone(bundle).(*types.Bundle) + b.SequenceNumber = uint64(i + 1) + if _, perr := p.PublishBundle(context.Background(), &bundlepublisherv1.PublishBundleRequest{ + Bundle: b, + }); perr != nil { + errCh <- perr + return + } + } + }() + + wg.Wait() + close(errCh) + for e := range errCh { + require.NoError(t, e) + } +} + type fakeClient struct { t *testing.T diff --git a/pkg/server/plugin/bundlepublisher/gcpcloudstorage/gcpcloudstorage.go b/pkg/server/plugin/bundlepublisher/gcpcloudstorage/gcpcloudstorage.go index 8dcff56b62..4be311d97f 100644 --- a/pkg/server/plugin/bundlepublisher/gcpcloudstorage/gcpcloudstorage.go +++ b/pkg/server/plugin/bundlepublisher/gcpcloudstorage/gcpcloudstorage.go @@ -141,9 +141,10 @@ func (p *Plugin) Configure(ctx context.Context, req *configv1.ConfigureRequest) if err != nil { return nil, status.Errorf(codes.Internal, "failed to create client: %v", err) } - p.gcsClient = gcsClient - - p.setConfig(newConfig) + // Store the config and client together under one lock so that + // PublishBundle always observes a matching pair, even when Configure + // runs concurrently as a result of a dynamic reconfiguration. + p.setConfig(newConfig, gcsClient) p.setBundle(nil) @@ -161,7 +162,7 @@ func (p *Plugin) Validate(ctx context.Context, req *configv1.ValidateRequest) (* // PublishBundle puts the bundle in the configured GCS bucket and object name. func (p *Plugin) PublishBundle(ctx context.Context, req *bundlepublisherv1.PublishBundleRequest) (*bundlepublisherv1.PublishBundleResponse, error) { - config, err := p.getConfig() + config, gcsClient, err := p.getConfig() if err != nil { return nil, err } @@ -187,7 +188,7 @@ func (p *Plugin) PublishBundle(ctx context.Context, req *bundlepublisherv1.Publi return nil, status.Errorf(codes.Internal, "could not format bundle: %v", err.Error()) } - bucketHandle := p.gcsClient.Bucket(config.BucketName) + bucketHandle := gcsClient.Bucket(config.BucketName) if bucketHandle == nil { // Purely defensive, the Bucket function implemented in GCS always returns a BucketHandle. return nil, status.Error(codes.Internal, "could not get bucket handle") } @@ -233,30 +234,36 @@ func (p *Plugin) PublishBundle(ctx context.Context, req *bundlepublisherv1.Publi // Close is called when the plugin is unloaded. Closes the client. func (p *Plugin) Close() error { - if p.gcsClient == nil { + p.configMtx.RLock() + gcsClient := p.gcsClient + p.configMtx.RUnlock() + + if gcsClient == nil { return nil } p.log.Debug("Closing the connection to the Cloud Storage API service") - return p.gcsClient.Close() + return gcsClient.Close() } // getBundle gets the latest bundle that the plugin has. func (p *Plugin) getBundle() *types.Bundle { - p.configMtx.RLock() - defer p.configMtx.RUnlock() + p.bundleMtx.RLock() + defer p.bundleMtx.RUnlock() return p.bundle } -// getConfig gets the configuration of the plugin. -func (p *Plugin) getConfig() (*Config, error) { +// getConfig gets the configuration of the plugin along with the client +// created for it. Both are returned together so callers always observe a +// matching pair. +func (p *Plugin) getConfig() (*Config, gcsService, error) { p.configMtx.RLock() defer p.configMtx.RUnlock() if p.config == nil { - return nil, status.Error(codes.FailedPrecondition, "not configured") + return nil, nil, status.Error(codes.FailedPrecondition, "not configured") } - return p.config, nil + return p.config, p.gcsClient, nil } // setBundle updates the current bundle in the plugin with the provided bundle. @@ -267,12 +274,14 @@ func (p *Plugin) setBundle(bundle *types.Bundle) { p.bundle = bundle } -// setConfig sets the configuration for the plugin. -func (p *Plugin) setConfig(config *Config) { +// setConfig sets the configuration for the plugin along with the client +// created for it, updating both atomically under one lock. +func (p *Plugin) setConfig(config *Config, client gcsService) { p.configMtx.Lock() defer p.configMtx.Unlock() p.config = config + p.gcsClient = client } // builtin creates a new BundlePublisher built-in plugin. diff --git a/pkg/server/plugin/bundlepublisher/gcpcloudstorage/gcpcloudstorage_test.go b/pkg/server/plugin/bundlepublisher/gcpcloudstorage/gcpcloudstorage_test.go index f0e5b4ad14..a52e05ec82 100644 --- a/pkg/server/plugin/bundlepublisher/gcpcloudstorage/gcpcloudstorage_test.go +++ b/pkg/server/plugin/bundlepublisher/gcpcloudstorage/gcpcloudstorage_test.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "crypto/x509" + "encoding/json" "errors" "io" + "sync" "testing" "time" @@ -23,6 +25,7 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/api/option" "google.golang.org/grpc/codes" + "google.golang.org/protobuf/proto" ) func TestConfigure(t *testing.T) { @@ -475,6 +478,81 @@ func TestBundleWithRefreshHintPublishedOnce(t *testing.T) { require.Equal(t, 1, testWriteObjectCount) } +// TestConcurrentConfigureAndPublish exercises Configure running concurrently +// with PublishBundle, as happens with dynamic reconfiguration. It is meant to +// be run with the race detector to catch unsynchronized access to the client. +func TestConcurrentConfigureAndPublish(t *testing.T) { + config := &Config{ + BucketName: "bucket-name", + ObjectName: "object-name", + Format: "spiffe", + } + + newClient := func(ctx context.Context, opts ...option.ClientOption) (gcsService, error) { + return &fakeClient{}, nil + } + newStorageWriter := func(ctx context.Context, o *storage.ObjectHandle) io.WriteCloser { + return &fakeStorageWriter{} + } + p := newPlugin(newClient, newStorageWriter) + + var err error + plugintest.Load(t, builtin(p), nil, + plugintest.CaptureConfigureError(&err), + plugintest.CoreConfig(catalog.CoreConfig{ + TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), + }), + plugintest.ConfigureJSON(config), + ) + require.NoError(t, err) + + hclConfig, err := json.Marshal(config) + require.NoError(t, err) + configureReq := &configv1.ConfigureRequest{ + CoreConfiguration: &configv1.CoreConfiguration{TrustDomain: "example.org"}, + HclConfiguration: string(hclConfig), + } + + bundle := getTestBundle(t) + + const iterations = 100 + errCh := make(chan error, 2) + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + for range iterations { + if _, cerr := p.Configure(context.Background(), configureReq); cerr != nil { + errCh <- cerr + return + } + } + }() + + go func() { + defer wg.Done() + for i := range iterations { + // Vary the bundle so the dedup check does not short-circuit and + // PublishBundle reaches the client. + b := proto.Clone(bundle).(*types.Bundle) + b.SequenceNumber = uint64(i + 1) + if _, perr := p.PublishBundle(context.Background(), &bundlepublisherv1.PublishBundleRequest{ + Bundle: b, + }); perr != nil { + errCh <- perr + return + } + } + }() + + wg.Wait() + close(errCh) + for e := range errCh { + require.NoError(t, e) + } +} + type fakeClient struct { clientOptions []option.ClientOption } diff --git a/pkg/server/plugin/keymanager/awskms/awskms.go b/pkg/server/plugin/keymanager/awskms/awskms.go index f312595d98..3d4588244d 100644 --- a/pkg/server/plugin/keymanager/awskms/awskms.go +++ b/pkg/server/plugin/keymanager/awskms/awskms.go @@ -9,6 +9,7 @@ import ( "os" "path" "regexp" + "strconv" "strings" "sync" "time" @@ -17,6 +18,8 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/aws/aws-sdk-go-v2/service/kms/types" + "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" + rgtatypes "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi/types" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/gofrs/uuid/v5" "github.com/hashicorp/go-hclog" @@ -34,16 +37,29 @@ const ( pluginName = "aws_kms" aliasPrefix = "alias/SPIRE_SERVER/" + // Logging tags keyArnTag = "key_arn" aliasNameTag = "alias_name" reasonTag = "reason" + // KMS resource tags for key discovery + tagKeyServerTD = "spire-server-td" // Trust domain (no hashing needed - AWS allows dots and long values) + tagKeyServerID = "spire-server-id" // Server identifier + tagKeyLastUpdate = "spire-last-update" // Unix timestamp of last update + tagKeyActive = "spire-active" // "true" if key is actively managed + tagKeySPIREKeyID = "spire-key-id" // SPIRE key identifier + + // Alias-based discovery task frequencies (legacy; will be deprecated in a future version and removed in a later one) refreshAliasesFrequency = time.Hour * 6 disposeAliasesFrequency = time.Hour * 24 aliasThreshold = time.Hour * 24 * 14 // two weeks + disposeKeysFrequency = time.Hour * 48 + keyThreshold = time.Hour * 48 // 48 hours for orphaned keys without aliases - disposeKeysFrequency = time.Hour * 48 - keyThreshold = time.Hour * 48 + // Tag-based discovery task frequencies + keepActiveKeysFrequency = time.Hour * 6 + disposeKeysViaTagsFrequency = time.Hour * 48 + keyThresholdForTagDiscovery = time.Hour * 24 * 14 // two weeks for tagged keys ) var ( @@ -69,14 +85,16 @@ type keyEntry struct { } type pluginHooks struct { - newKMSClient func(aws.Config) (kmsClient, error) - newSTSClient func(aws.Config) (stsClient, error) - clk clock.Clock + newKMSClient func(aws.Config) (kmsClient, error) + newTaggingClient func(aws.Config) (taggingClient, error) + newSTSClient func(aws.Config) (stsClient, error) + clk clock.Clock // just for testing scheduleDeleteSignal chan error refreshAliasesSignal chan error disposeAliasesSignal chan error disposeKeysSignal chan error + keepActiveKeysSignal chan error } // Plugin is the main representation of this keymanager plugin @@ -88,6 +106,7 @@ type Plugin struct { mu sync.RWMutex entries map[string]keyEntry kmsClient kmsClient + taggingClient taggingClient stsClient stsClient trustDomain string serverID string @@ -96,6 +115,9 @@ type Plugin struct { hooks pluginHooks keyPolicy *string keyTags []types.Tag + + // useTagBasedDiscovery indicates whether to use tag-based or alias-based key discovery + useTagBasedDiscovery bool } // Config provides configuration context for the plugin @@ -107,6 +129,20 @@ type Config struct { KeyIdentifierValue string `hcl:"key_identifier_value" json:"key_identifier_value"` KeyPolicyFile string `hcl:"key_policy_file" json:"key_policy_file"` KeyTags map[string]string `hcl:"key_tags" json:"key_tags"` + + // EnableTagBasedKeyDiscovery enables the use of AWS Resource Groups Tagging API + // for efficient key discovery instead of the legacy alias-based approach. + // When enabled, keys are discovered using SPIRE-specific tags (spire-server-td, + // spire-server-id, spire-active). + // This eliminates the need for broad ListKeys + DescribeKey permissions and reduces API costs. + // + // Default: false (uses legacy alias-based discovery) + // In a future SPIRE version, this will default to true. The alias-based + // approach will be deprecated in a future version and removed in a later one. + // + // Note: When enabled, the plugin requires the tag:GetResources IAM + // permission (from the AWS Resource Groups Tagging API). + EnableTagBasedKeyDiscovery bool `hcl:"enable_tag_based_key_discovery" json:"enable_tag_based_key_discovery"` } func buildConfig(coreConfig catalog.CoreConfig, hclText string, status *pluginconf.Status) *Config { @@ -152,19 +188,21 @@ func buildConfig(coreConfig catalog.CoreConfig, hclText string, status *pluginco // New returns an instantiated plugin func New() *Plugin { - return newPlugin(newKMSClient, newSTSClient) + return newPlugin(newKMSClient, newTaggingClient, newSTSClient) } func newPlugin( newKMSClient func(aws.Config) (kmsClient, error), + newTaggingClient func(aws.Config) (taggingClient, error), newSTSClient func(aws.Config) (stsClient, error), ) *Plugin { return &Plugin{ entries: make(map[string]keyEntry), hooks: pluginHooks{ - newKMSClient: newKMSClient, - newSTSClient: newSTSClient, - clk: clock.New(), + newKMSClient: newKMSClient, + newTaggingClient: newTaggingClient, + newSTSClient: newSTSClient, + clk: clock.New(), }, scheduleDelete: make(chan string, 120), } @@ -215,16 +253,57 @@ func (p *Plugin) Configure(ctx context.Context, req *configv1.ConfigureRequest) return nil, status.Errorf(codes.Internal, "failed to create KMS client: %v", err) } - fetcher := &keyFetcher{ - log: p.log, - kmsClient: kc, - serverID: serverID, - trustDomain: req.CoreConfiguration.TrustDomain, - } - p.log.Debug("Fetching key aliases from KMS") - keyEntries, err := fetcher.fetchKeyEntries(ctx) - if err != nil { - return nil, err + // Determine which discovery mode to use + useTagBasedDiscovery := newConfig.EnableTagBasedKeyDiscovery + + if useTagBasedDiscovery { + p.log.Info("Tag-based key discovery enabled") + } else { + p.log.Warn("Alias-based key discovery will be deprecated in a future version and removed in a later one. " + + "Enable 'enable_tag_based_key_discovery' to switch to tag-based discovery, which efficiently " + + "finds only the keys managed by this plugin instance.") + } + + // Initialize the appropriate fetcher based on configuration + var keyEntries []*keyEntry + var spireTags []types.Tag + if useTagBasedDiscovery { + // Build SPIRE-specific tags so they can be used during migration + // and applied to newly created keys. + spireTags = p.buildSPIRETags(serverID, req.CoreConfiguration.TrustDomain) + // Create tagging client for tag-based discovery + tc, err := p.hooks.newTaggingClient(awsCfg) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to create tagging client: %v", err) + } + + fetcher := &keyFetcher{ + log: p.log, + kmsClient: kc, + taggingClient: tc, + serverID: serverID, + trustDomain: req.CoreConfiguration.TrustDomain, + } + p.log.Debug("Fetching keys using tag-based discovery from AWS Resource Groups Tagging API") + lastUpdate := strconv.FormatInt(p.hooks.clk.Now().Unix(), 10) + keyEntries, err = fetcher.fetchKeyEntriesWithMigration(ctx, spireTags, lastUpdate) + if err != nil { + return nil, err + } + p.taggingClient = tc + } else { + // Use legacy alias-based discovery + fetcher := &keyFetcher{ + log: p.log, + kmsClient: kc, + serverID: serverID, + trustDomain: req.CoreConfiguration.TrustDomain, + } + p.log.Debug("Fetching keys using legacy alias-based discovery from KMS") + keyEntries, err = fetcher.fetchKeyEntriesViaAlias(ctx) + if err != nil { + return nil, err + } } p.mu.Lock() @@ -235,10 +314,23 @@ func (p *Plugin) Configure(ctx context.Context, req *configv1.ConfigureRequest) p.stsClient = sc p.trustDomain = req.CoreConfiguration.TrustDomain p.serverID = serverID + p.useTagBasedDiscovery = useTagBasedDiscovery - if len(newConfig.KeyTags) > 0 { + // Build the tag list applied to every new key. SPIRE-specific tags are + // only included when tag-based discovery is enabled, so that the legacy + // alias-based path does not require the kms:TagResource permission. + switch { + case useTagBasedDiscovery && len(newConfig.KeyTags) > 0: + userTags := buildKeyTags(newConfig.KeyTags) + // Build a fresh slice to avoid mutating the spireTags backing array. + p.keyTags = make([]types.Tag, 0, len(spireTags)+len(userTags)) + p.keyTags = append(p.keyTags, spireTags...) + p.keyTags = append(p.keyTags, userTags...) + case useTagBasedDiscovery: + p.keyTags = spireTags + case len(newConfig.KeyTags) > 0: p.keyTags = buildKeyTags(newConfig.KeyTags) - } else { + default: p.keyTags = nil } @@ -247,12 +339,21 @@ func (p *Plugin) Configure(ctx context.Context, req *configv1.ConfigureRequest) p.cancelTasks() } - // start tasks + // Start background tasks based on discovery mode ctx, p.cancelTasks = context.WithCancel(context.Background()) go p.scheduleDeleteTask(ctx) + + // Always refresh aliases so a downgrade to a version without + // tag-based discovery still finds keys with fresh aliases. go p.refreshAliasesTask(ctx) - go p.disposeAliasesTask(ctx) - go p.disposeKeysTask(ctx) + + if useTagBasedDiscovery { + go p.keepActiveKeysTask(ctx) + go p.disposeKeysViaTagsTask(ctx) + } else { + go p.disposeAliasesTask(ctx) + go p.disposeKeysTask(ctx) + } return &configv1.ConfigureResponse{}, nil } @@ -387,7 +488,30 @@ func (p *Plugin) createKey(ctx context.Context, spireKeyID string, keyType keyma Policy: p.keyPolicy, } - if len(p.keyTags) > 0 { + if p.useTagBasedDiscovery { + // When tag-based discovery is enabled, append the per-key SPIRE key + // ID tag so the key can be looked up by ID via the tagging API, and + // stamp spire-last-update so the key is immediately eligible for + // staleness evaluation. Stamping at creation (rather than waiting for + // the first keepActiveKeys tick) ensures a key is never left with + // spire-active=true but no spire-last-update, which would make it + // undisposable by other servers if this server dies before that tick. + // Build a fresh slice to avoid mutating the shared p.keyTags slice. + tags := make([]types.Tag, len(p.keyTags), len(p.keyTags)+2) + copy(tags, p.keyTags) + tags = append(tags, + types.Tag{ + TagKey: aws.String(tagKeySPIREKeyID), + TagValue: aws.String(spireKeyID), + }, + types.Tag{ + TagKey: aws.String(tagKeyLastUpdate), + TagValue: aws.String(strconv.FormatInt(p.hooks.clk.Now().Unix(), 10)), + }, + ) + createKeyInput.Tags = tags + } else if len(p.keyTags) > 0 { + // Legacy alias-based mode: only apply user-defined tags (if any). createKeyInput.Tags = p.keyTags } @@ -1078,6 +1202,30 @@ func buildKeyTags(tags map[string]string) []types.Tag { return keyTags } +// buildSPIRETags creates the SPIRE-specific tags that are added to all KMS keys +// at creation time. These tags enable efficient key discovery via the AWS +// Resource Groups Tagging API. +// +// Note: spire-last-update is intentionally omitted here. It is stamped +// separately at key creation and during migration (with the current +// timestamp) and refreshed on a regular schedule by keepActiveKeys. +func (p *Plugin) buildSPIRETags(serverID, trustDomain string) []types.Tag { + return []types.Tag{ + { + TagKey: aws.String(tagKeyServerTD), + TagValue: aws.String(trustDomain), + }, + { + TagKey: aws.String(tagKeyServerID), + TagValue: aws.String(serverID), + }, + { + TagKey: aws.String(tagKeyActive), + TagValue: aws.String("true"), + }, + } +} + // encodeKeyID maps "." and "+" characters to the asciihex value using "_" as // escape character. Currently, KMS does not support those characters to be used // as alias name. @@ -1094,3 +1242,227 @@ func decodeKeyID(keyID string) string { keyID = strings.ReplaceAll(keyID, "_2b", "+") return keyID } + +// keepActiveKeysTask updates the spire-last-update tag on all managed keys every 6 hours. +// This allows detection of keys that are no longer in use by any server. +// This task only runs when tag-based discovery is enabled. +func (p *Plugin) keepActiveKeysTask(ctx context.Context) { + ticker := p.hooks.clk.Ticker(keepActiveKeysFrequency) + defer ticker.Stop() + + p.notifyKeepActiveKeys(nil) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + err := p.keepActiveKeys(ctx) + p.notifyKeepActiveKeys(err) + } + } +} + +// keepActiveKeys updates the last-update tag on all keys managed by this server. +func (p *Plugin) keepActiveKeys(ctx context.Context) error { + p.log.Debug("Updating last-update tag on managed keys") + + // Snapshot entries under the lock so we don't hold it across network calls. + p.mu.RLock() + entries := make([]keyEntry, 0, len(p.entries)) + for _, e := range p.entries { + entries = append(entries, e) + } + p.mu.RUnlock() + + now := strconv.FormatInt(p.hooks.clk.Now().Unix(), 10) + var errs []string + + for _, entry := range entries { + _, err := p.kmsClient.TagResource(ctx, &kms.TagResourceInput{ + KeyId: &entry.Arn, + Tags: []types.Tag{ + { + TagKey: aws.String(tagKeyLastUpdate), + TagValue: aws.String(now), + }, + }, + }) + if err != nil { + p.log.Error("Failed to update last-update tag", keyArnTag, entry.Arn, reasonTag, err) + errs = append(errs, err.Error()) + } + } + + if errs != nil { + return errors.New(strings.Join(errs, ": ")) + } + return nil +} + +// disposeKeysViaTagsTask finds and disposes of stale keys using tag-based filtering. +// This runs every 48 hours and looks for keys with spire-active=true but with +// a spire-last-update timestamp older than 2 weeks that don't belong to this server. +// This is the tag-based equivalent of disposeAliasesTask. +func (p *Plugin) disposeKeysViaTagsTask(ctx context.Context) { + ticker := p.hooks.clk.Ticker(disposeKeysViaTagsFrequency) + defer ticker.Stop() + + p.notifyDisposeKeys(nil) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + err := p.disposeKeysViaTags(ctx) + p.notifyDisposeKeys(err) + } + } +} + +// disposeKeysViaTags uses the AWS Resource Groups Tagging API to find stale keys. +func (p *Plugin) disposeKeysViaTags(ctx context.Context) error { + p.log.Debug("Looking for stale keys to dispose using tag-based discovery") + + now := p.hooks.clk.Now() + staleThreshold := now.Add(-keyThresholdForTagDiscovery).Unix() + + // Find all keys in this trust domain that are active + tagFilters := []rgtatypes.TagFilter{ + { + Key: aws.String(tagKeyServerTD), + Values: []string{p.trustDomain}, + }, + { + Key: aws.String(tagKeyActive), + Values: []string{"true"}, + }, + } + + paginator := resourcegroupstaggingapi.NewGetResourcesPaginator(p.taggingClient, &resourcegroupstaggingapi.GetResourcesInput{ + ResourceTypeFilters: []string{"kms:key"}, + TagFilters: tagFilters, + }) + + var errs []string + for { + resourcesResp, err := paginator.NextPage(ctx) + switch { + case err != nil: + if permErr := tagGetResourcesPermissionError(err); permErr != nil { + p.log.Error("Failed to fetch keys for disposal", reasonTag, permErr) + return permErr + } + p.log.Error("Failed to fetch keys for disposal", reasonTag, err) + return err + case resourcesResp == nil: + p.log.Error("Failed to fetch keys for disposal: nil response") + return errors.New("nil response from tagging API") + } + + for _, resource := range resourcesResp.ResourceTagMappingList { + if resource.ResourceARN == nil { + continue + } + + keyArn := *resource.ResourceARN + + // Check if this key belongs to the current server + var belongsToThisServer bool + var lastUpdateTimestamp int64 + var hasLastUpdate bool + var malformedTimestamp bool + for _, tag := range resource.Tags { + if tag.Key != nil && *tag.Key == tagKeyServerID && tag.Value != nil && *tag.Value == p.serverID { + belongsToThisServer = true + } + if tag.Key != nil && *tag.Key == tagKeyLastUpdate && tag.Value != nil { + ts, err := strconv.ParseInt(*tag.Value, 10, 64) + if err != nil { + malformedTimestamp = true + continue + } + lastUpdateTimestamp = ts + hasLastUpdate = true + } + } + + if malformedTimestamp && !hasLastUpdate { + p.log.Warn("Malformed spire-last-update tag value, skipping key", + keyArnTag, keyArn) + continue + } + + // Skip keys belonging to this server + if belongsToThisServer { + continue + } + + // Skip keys that have been updated recently. A key with no + // spire-last-update tag has lastUpdateTimestamp == 0 (the Unix + // epoch), the oldest possible value, so it is treated as stale: + // every key managed by the plugin is stamped at creation and + // migration, and a missing value indicates an abandoned key. + if lastUpdateTimestamp > staleThreshold { + continue + } + + log := p.log.With(keyArnTag, keyArn) + log.Debug("Found stale key beyond threshold") + + // Schedule the key for deletion synchronously and only mark it + // inactive once the deletion has been scheduled. Marking + // spire-active=false drops the key from the GetResources(active=true) + // filter, so doing it before the deletion is actually scheduled + // could orphan the key if the server stops in between, and tag mode + // has no creation-date orphan sweeper to fall back on like the + // legacy alias path does. + _, err := p.kmsClient.ScheduleKeyDeletion(ctx, &kms.ScheduleKeyDeletionInput{ + KeyId: &keyArn, + PendingWindowInDays: aws.Int32(7), + }) + // A key that is already pending deletion (for example, scheduled on + // a previous cycle that stopped before marking it inactive) is + // treated as success so the spire-active tag can still be cleared. + // Any other error leaves the key active so it is retried next cycle. + var invalidState *types.KMSInvalidStateException + if err != nil && !errors.As(err, &invalidState) { + log.Error("Failed to schedule key for deletion", reasonTag, err) + errs = append(errs, err.Error()) + continue + } + log.Debug("Key scheduled for deletion") + + // Mark the key as inactive by updating the spire-active tag. + _, err = p.kmsClient.TagResource(ctx, &kms.TagResourceInput{ + KeyId: &keyArn, + Tags: []types.Tag{ + { + TagKey: aws.String(tagKeyActive), + TagValue: aws.String("false"), + }, + }, + }) + if err != nil { + log.Error("Failed to mark key as inactive", reasonTag, err) + errs = append(errs, err.Error()) + } + } + + if !paginator.HasMorePages() { + break + } + } + + if errs != nil { + return errors.New(strings.Join(errs, ": ")) + } + return nil +} + +func (p *Plugin) notifyKeepActiveKeys(err error) { + if p.hooks.keepActiveKeysSignal != nil { + p.hooks.keepActiveKeysSignal <- err + } +} diff --git a/pkg/server/plugin/keymanager/awskms/awskms_test.go b/pkg/server/plugin/keymanager/awskms/awskms_test.go index 217f319285..723afa8e22 100644 --- a/pkg/server/plugin/keymanager/awskms/awskms_test.go +++ b/pkg/server/plugin/keymanager/awskms/awskms_test.go @@ -12,6 +12,7 @@ import ( "path" "path/filepath" "runtime" + "strconv" "strings" "testing" "time" @@ -19,6 +20,8 @@ import ( "github.com/andres-erbsen/clock" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/kms/types" + rgtatypes "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi/types" + "github.com/aws/smithy-go" "github.com/sirupsen/logrus" "github.com/sirupsen/logrus/hooks/test" "github.com/spiffe/go-spiffe/v2/spiffeid" @@ -90,8 +93,10 @@ func TestKeyManagerContract(t *testing.T) { c := clock.NewMock() fakeKMSClient := newKMSClientFake(t, c) fakeSTSClient := newSTSClientFake() + fakeTaggingClient := newTaggingClientFake() p := newPlugin( func(aws.Config) (kmsClient, error) { return fakeKMSClient, nil }, + func(aws.Config) (taggingClient, error) { return fakeTaggingClient, nil }, func(aws.Config) (stsClient, error) { return fakeSTSClient, nil }, ) km := new(keymanager.V1) @@ -121,11 +126,12 @@ func TestKeyManagerContract(t *testing.T) { } type pluginTest struct { - plugin *Plugin - fakeKMSClient *kmsClientFake - fakeSTSClient *stsClientFake - logHook *test.Hook - clockHook *clock.Mock + plugin *Plugin + fakeKMSClient *kmsClientFake + fakeSTSClient *stsClientFake + fakeTaggingClient *taggingClientFake + logHook *test.Hook + clockHook *clock.Mock } func setupTest(t *testing.T) *pluginTest { @@ -135,8 +141,10 @@ func setupTest(t *testing.T) *pluginTest { c := clock.NewMock() fakeKMSClient := newKMSClientFake(t, c) fakeSTSClient := newSTSClientFake() + fakeTaggingClient := newTaggingClientFake() p := newPlugin( func(aws.Config) (kmsClient, error) { return fakeKMSClient, nil }, + func(aws.Config) (taggingClient, error) { return fakeTaggingClient, nil }, func(aws.Config) (stsClient, error) { return fakeSTSClient, nil }, ) km := new(keymanager.V1) @@ -145,11 +153,12 @@ func setupTest(t *testing.T) *pluginTest { p.hooks.clk = c return &pluginTest{ - plugin: p, - fakeKMSClient: fakeKMSClient, - fakeSTSClient: fakeSTSClient, - logHook: logHook, - clockHook: c, + plugin: p, + fakeKMSClient: fakeKMSClient, + fakeSTSClient: fakeSTSClient, + fakeTaggingClient: fakeTaggingClient, + logHook: logHook, + clockHook: c, } } @@ -2563,3 +2572,603 @@ func waitForSignal(t *testing.T, ch chan error) error { } return nil } + +// configureTagBasedRequest returns a ConfigureRequest with tag-based key +// discovery enabled, using the validServerID file for the server identifier. +func configureTagBasedRequest(t *testing.T) *configv1.ConfigureRequest { + return &configv1.ConfigureRequest{ + CoreConfiguration: &configv1.CoreConfiguration{TrustDomain: "test.example.org"}, + HclConfiguration: fmt.Sprintf(`{ + "access_key_id": %q, + "secret_access_key": %q, + "region": %q, + "key_identifier_file": %q, + "enable_tag_based_key_discovery": true + }`, validAccessKeyID, validSecretAccessKey, validRegion, getKeyIdentifierFile(t)), + } +} + +// makeTaggedResource builds a ResourceTagMapping representing a KMS key that +// is actively managed by the given server, with spire-key-id set. +func makeTaggedResource(keyArn, spireKeyID, serverID, trustDomain string) rgtatypes.ResourceTagMapping { + return rgtatypes.ResourceTagMapping{ + ResourceARN: aws.String(keyArn), + Tags: []rgtatypes.Tag{ + {Key: aws.String(tagKeyServerTD), Value: aws.String(trustDomain)}, + {Key: aws.String(tagKeyServerID), Value: aws.String(serverID)}, + {Key: aws.String(tagKeyActive), Value: aws.String("true")}, + {Key: aws.String(tagKeySPIREKeyID), Value: aws.String(spireKeyID)}, + }, + } +} + +func TestConfigureWithTagBasedDiscovery(t *testing.T) { + const ( + tagKeyID = "tag-key-01" + tagKeyArn = fakeKeyArnPrefix + tagKeyID + tagSpireKey = "x509-CA-A" + ) + + for _, tt := range []struct { + name string + err string + code codes.Code + fakeEntries []fakeKeyEntry + taggedResources []rgtatypes.ResourceTagMapping + getResourcesErr error + tagResourceErr string + expectEntryCount int + expectTagResourceCalls int + }{ + { + name: "load keys via tag-based discovery", + fakeEntries: []fakeKeyEntry{ + { + KeyID: aws.String(tagKeyID), + KeySpec: types.KeySpecEccNistP256, + Enabled: true, + PublicKey: []byte("fake-public-key"), + }, + }, + taggedResources: []rgtatypes.ResourceTagMapping{ + makeTaggedResource(tagKeyArn, tagSpireKey, validServerID, "test.example.org"), + }, + expectEntryCount: 1, + expectTagResourceCalls: 0, + }, + { + name: "migrate legacy keys to tag-based discovery", + fakeEntries: []fakeKeyEntry{ + { + AliasName: aws.String(aliasName), + KeyID: aws.String(keyID), + KeySpec: types.KeySpecEccNistP256, + Enabled: true, + PublicKey: []byte("fake-public-key"), + }, + }, + // tagging fake returns empty — key has no SPIRE tags yet + expectEntryCount: 1, + expectTagResourceCalls: 1, + }, + { + name: "deduplicate key found via both tag and alias", + fakeEntries: []fakeKeyEntry{ + { + AliasName: aws.String(aliasName), + KeyID: aws.String(keyID), + KeySpec: types.KeySpecEccNistP256, + Enabled: true, + PublicKey: []byte("fake-public-key"), + }, + }, + // tagging fake already has the key — no migration needed + taggedResources: []rgtatypes.ResourceTagMapping{ + makeTaggedResource(KeyArn, spireKeyID, validServerID, "test.example.org"), + }, + expectEntryCount: 1, + expectTagResourceCalls: 0, + }, + { + name: "migration: tag resource error is non-fatal", + fakeEntries: []fakeKeyEntry{ + { + AliasName: aws.String(aliasName), + KeyID: aws.String(keyID), + KeySpec: types.KeySpecEccNistP256, + Enabled: true, + PublicKey: []byte("fake-public-key"), + }, + }, + tagResourceErr: "tag resource failed", + expectEntryCount: 1, + }, + { + // A key found via tags but disabled or pending deletion (e.g. + // scheduled for deletion by the legacy alias path, which does not + // clear SPIRE tags) is skipped rather than failing startup. + name: "skips disabled key found via tags", + fakeEntries: []fakeKeyEntry{ + { + KeyID: aws.String(tagKeyID), + KeySpec: types.KeySpecEccNistP256, + Enabled: false, + PublicKey: []byte("fake-public-key"), + }, + }, + taggedResources: []rgtatypes.ResourceTagMapping{ + makeTaggedResource(tagKeyArn, tagSpireKey, validServerID, "test.example.org"), + }, + expectEntryCount: 0, + expectTagResourceCalls: 0, + }, + { + name: "GetResources error fails configure", + getResourcesErr: errors.New("tagging API unavailable"), + err: "failed to fetch keys by tags: tagging API unavailable", + code: codes.Internal, + }, + { + name: "GetResources access denied returns actionable error", + getResourcesErr: &smithy.GenericAPIError{ + Code: "AccessDeniedException", + Message: "User is not authorized to perform: tag:GetResources", + }, + err: `tag-based key discovery requires the "tag:GetResources" permission`, + code: codes.FailedPrecondition, + }, + } { + t.Run(tt.name, func(t *testing.T) { + ts := setupTest(t) + ts.fakeKMSClient.setEntries(tt.fakeEntries) + ts.fakeTaggingClient.setResources(tt.taggedResources) + ts.fakeTaggingClient.setErr(tt.getResourcesErr) + ts.fakeKMSClient.setTagResourceErr(tt.tagResourceErr) + + _, err := ts.plugin.Configure(ctx, configureTagBasedRequest(t)) + + if tt.err != "" { + spiretest.RequireGRPCStatusContains(t, err, tt.code, tt.err) + return + } + require.NoError(t, err) + + ts.plugin.mu.RLock() + entryCount := len(ts.plugin.entries) + ts.plugin.mu.RUnlock() + require.Equal(t, tt.expectEntryCount, entryCount) + + ts.fakeKMSClient.mu.RLock() + tagCalls := len(ts.fakeKMSClient.tagResourceCalls) + ts.fakeKMSClient.mu.RUnlock() + require.Equal(t, tt.expectTagResourceCalls, tagCalls) + }) + } +} + +// TestGenerateKeyTagBased verifies that keys created while tag-based discovery +// is enabled are stamped at creation with the SPIRE discovery tags, including +// spire-last-update, so they are immediately eligible for staleness evaluation +// and never left undisposable if the server dies before keepActiveKeys runs. +func TestGenerateKeyTagBased(t *testing.T) { + ts := setupTest(t) + + _, err := ts.plugin.Configure(ctx, configureTagBasedRequest(t)) + require.NoError(t, err) + + _, err = ts.plugin.GenerateKey(ctx, &keymanagerv1.GenerateKeyRequest{ + KeyId: spireKeyID, + KeyType: keymanagerv1.KeyType_EC_P256, + }) + require.NoError(t, err) + + ts.fakeKMSClient.mu.RLock() + createCalls := ts.fakeKMSClient.createKeyCalls + ts.fakeKMSClient.mu.RUnlock() + require.Len(t, createCalls, 1) + + tags := make(map[string]string, len(createCalls[0].Tags)) + for _, tag := range createCalls[0].Tags { + require.NotNil(t, tag.TagKey) + require.NotNil(t, tag.TagValue) + tags[*tag.TagKey] = *tag.TagValue + } + + require.Equal(t, "test.example.org", tags[tagKeyServerTD]) + require.Equal(t, validServerID, tags[tagKeyServerID]) + require.Equal(t, "true", tags[tagKeyActive]) + require.Equal(t, spireKeyID, tags[tagKeySPIREKeyID]) + require.Equal(t, strconv.FormatInt(ts.clockHook.Now().Unix(), 10), tags[tagKeyLastUpdate]) +} + +// TestFetchKeyEntryDetailsFromArn covers the defensive error branches of +// tag-based key detail retrieval that are not exercised by the higher-level +// Configure tests. +func TestFetchKeyEntryDetailsFromArn(t *testing.T) { + const ( + fdKeyID = "fd-key-01" + fdKeyArn = fakeKeyArnPrefix + fdKeyID + ) + + for _, tt := range []struct { + name string + fakeEntries []fakeKeyEntry + describeKeyErr string + describeMalformed bool + getPublicKeyErr string + expectErr string + expectNilEntry bool + }{ + { + name: "describe key error", + describeKeyErr: "describe boom", + expectErr: "failed to describe key: describe boom", + }, + { + name: "malformed describe response", + describeMalformed: true, + expectErr: "malformed describe key response", + }, + { + name: "disabled key is skipped", + fakeEntries: []fakeKeyEntry{ + {KeyID: aws.String(fdKeyID), KeySpec: types.KeySpecEccNistP256, Enabled: false, PublicKey: []byte("fake-public-key")}, + }, + expectNilEntry: true, + }, + { + name: "unsupported key spec", + fakeEntries: []fakeKeyEntry{ + {KeyID: aws.String(fdKeyID), KeySpec: types.KeySpec("BOGUS_SPEC"), Enabled: true, PublicKey: []byte("fake-public-key")}, + }, + expectErr: "unsupported key spec", + }, + { + name: "get public key error", + fakeEntries: []fakeKeyEntry{ + {KeyID: aws.String(fdKeyID), KeySpec: types.KeySpecEccNistP256, Enabled: true, PublicKey: []byte("fake-public-key")}, + }, + getPublicKeyErr: "getpub boom", + expectErr: "failed to get public key: getpub boom", + }, + { + name: "malformed get public key response", + fakeEntries: []fakeKeyEntry{ + {KeyID: aws.String(fdKeyID), KeySpec: types.KeySpecEccNistP256, Enabled: true, PublicKey: nil}, + }, + expectErr: "malformed get public key response", + }, + } { + t.Run(tt.name, func(t *testing.T) { + ts := setupTest(t) + ts.fakeKMSClient.setEntries(tt.fakeEntries) + ts.fakeKMSClient.setDescribeKeyErr(tt.describeKeyErr) + ts.fakeKMSClient.setDescribeKeyMalformed(tt.describeMalformed) + ts.fakeKMSClient.setgetPublicKeyErr(tt.getPublicKeyErr) + + kf := &keyFetcher{ + log: ts.plugin.log, + kmsClient: ts.fakeKMSClient, + serverID: validServerID, + trustDomain: "test.example.org", + } + + entry, err := kf.fetchKeyEntryDetailsFromArn(ctx, fdKeyArn, spireKeyID) + if tt.expectErr != "" { + require.ErrorContains(t, err, tt.expectErr) + require.Nil(t, entry) + return + } + require.NoError(t, err) + if tt.expectNilEntry { + require.Nil(t, entry) + return + } + require.NotNil(t, entry) + }) + } +} + +func TestKeepActiveKeys(t *testing.T) { + for _, tt := range []struct { + name string + err string + fakeEntries []fakeKeyEntry + tagResourceErr string + }{ + { + name: "updates spire-last-update tag on all managed entries", + fakeEntries: []fakeKeyEntry{ + { + AliasName: aws.String(aliasName), + KeyID: aws.String(keyID), + KeySpec: types.KeySpecEccNistP256, + Enabled: true, + PublicKey: []byte("fake-public-key"), + }, + { + AliasName: aws.String(aliasName + "01"), + KeyID: aws.String(keyID + "01"), + KeySpec: types.KeySpecEccNistP384, + Enabled: true, + PublicKey: []byte("fake-public-key"), + }, + }, + }, + { + name: "tag update errors are returned", + fakeEntries: []fakeKeyEntry{ + { + AliasName: aws.String(aliasName), + KeyID: aws.String(keyID), + KeySpec: types.KeySpecEccNistP256, + Enabled: true, + PublicKey: []byte("fake-public-key"), + }, + }, + tagResourceErr: "tag update failed", + err: "tag update failed", + }, + } { + t.Run(tt.name, func(t *testing.T) { + ts := setupTest(t) + ts.fakeKMSClient.setEntries(tt.fakeEntries) + + keepActiveKeysSignal := make(chan error) + ts.plugin.hooks.keepActiveKeysSignal = keepActiveKeysSignal + + _, err := ts.plugin.Configure(ctx, configureTagBasedRequest(t)) + require.NoError(t, err) + + // Wait for initial signal (no-op before the first tick) + _ = waitForSignal(t, keepActiveKeysSignal) + + // Reset calls accumulated during Configure's migration path. + ts.fakeKMSClient.mu.Lock() + ts.fakeKMSClient.tagResourceCalls = nil + ts.fakeKMSClient.mu.Unlock() + + ts.fakeKMSClient.setTagResourceErr(tt.tagResourceErr) + + ts.clockHook.Add(keepActiveKeysFrequency) + err = waitForSignal(t, keepActiveKeysSignal) + + if tt.err != "" { + require.EqualError(t, err, tt.err) + return + } + require.NoError(t, err) + + // One TagResource call per entry with spire-last-update + ts.fakeKMSClient.mu.RLock() + calls := ts.fakeKMSClient.tagResourceCalls + ts.fakeKMSClient.mu.RUnlock() + require.Len(t, calls, len(tt.fakeEntries)) + + expectedTime := strconv.FormatInt(ts.clockHook.Now().Unix(), 10) + for _, call := range calls { + require.Len(t, call.Tags, 1) + require.Equal(t, tagKeyLastUpdate, *call.Tags[0].TagKey) + require.Equal(t, expectedTime, *call.Tags[0].TagValue) + } + }) + } +} + +func TestDisposeKeysViaTags(t *testing.T) { + const ( + otherServerID = "other-server-id" + otherKeyArn = fakeKeyArnPrefix + "other-server-key" + staleTimestamp = int64(0) // Unix epoch — always older than the 2-week threshold + ) + + for _, tt := range []struct { + name string + err string + taggedResources []rgtatypes.ResourceTagMapping + getResourcesErr error + useRecentTimestamp bool + expectDeleteCount int + expectInactiveTagCount int + }{ + { + name: "disposes stale key from another server", + taggedResources: []rgtatypes.ResourceTagMapping{ + { + ResourceARN: aws.String(otherKeyArn), + Tags: []rgtatypes.Tag{ + {Key: aws.String(tagKeyServerTD), Value: aws.String("test.example.org")}, + {Key: aws.String(tagKeyServerID), Value: aws.String(otherServerID)}, + {Key: aws.String(tagKeyActive), Value: aws.String("true")}, + {Key: aws.String(tagKeyLastUpdate), Value: aws.String(strconv.FormatInt(staleTimestamp, 10))}, + }, + }, + }, + expectDeleteCount: 1, + expectInactiveTagCount: 1, + }, + { + name: "skips key belonging to this server", + taggedResources: []rgtatypes.ResourceTagMapping{ + { + ResourceARN: aws.String(otherKeyArn), + Tags: []rgtatypes.Tag{ + {Key: aws.String(tagKeyServerTD), Value: aws.String("test.example.org")}, + {Key: aws.String(tagKeyServerID), Value: aws.String(validServerID)}, + {Key: aws.String(tagKeyActive), Value: aws.String("true")}, + {Key: aws.String(tagKeyLastUpdate), Value: aws.String(strconv.FormatInt(staleTimestamp, 10))}, + }, + }, + }, + expectDeleteCount: 0, + expectInactiveTagCount: 0, + }, + { + // Every key managed by the plugin is stamped with spire-last-update + // at creation and migration, so an active key from another server + // that lacks the tag is treated as abandoned and disposed. + name: "disposes key with no spire-last-update tag", + taggedResources: []rgtatypes.ResourceTagMapping{ + { + ResourceARN: aws.String(otherKeyArn), + Tags: []rgtatypes.Tag{ + {Key: aws.String(tagKeyServerTD), Value: aws.String("test.example.org")}, + {Key: aws.String(tagKeyServerID), Value: aws.String(otherServerID)}, + {Key: aws.String(tagKeyActive), Value: aws.String("true")}, + // no spire-last-update tag + }, + }, + }, + expectDeleteCount: 1, + expectInactiveTagCount: 1, + }, + { + name: "skips key with malformed spire-last-update tag", + taggedResources: []rgtatypes.ResourceTagMapping{ + { + ResourceARN: aws.String(otherKeyArn), + Tags: []rgtatypes.Tag{ + {Key: aws.String(tagKeyServerTD), Value: aws.String("test.example.org")}, + {Key: aws.String(tagKeyServerID), Value: aws.String(otherServerID)}, + {Key: aws.String(tagKeyActive), Value: aws.String("true")}, + {Key: aws.String(tagKeyLastUpdate), Value: aws.String("not-a-number")}, + }, + }, + }, + expectDeleteCount: 0, + expectInactiveTagCount: 0, + }, + { + name: "skips recently updated key", + useRecentTimestamp: true, + expectDeleteCount: 0, + expectInactiveTagCount: 0, + }, + { + name: "GetResources error is returned", + getResourcesErr: errors.New("tagging API error"), + err: "tagging API error", + }, + } { + t.Run(tt.name, func(t *testing.T) { + ts := setupTest(t) + + taggedResources := tt.taggedResources + if tt.useRecentTimestamp { + // After advancing by keyThresholdForTagDiscovery, staleThreshold = epoch. + // Use epoch+1 so the key is strictly newer than the threshold. + recentTimestamp := ts.clockHook.Now().Unix() + 1 + taggedResources = []rgtatypes.ResourceTagMapping{ + { + ResourceARN: aws.String(otherKeyArn), + Tags: []rgtatypes.Tag{ + {Key: aws.String(tagKeyServerTD), Value: aws.String("test.example.org")}, + {Key: aws.String(tagKeyServerID), Value: aws.String(otherServerID)}, + {Key: aws.String(tagKeyActive), Value: aws.String("true")}, + {Key: aws.String(tagKeyLastUpdate), Value: aws.String(strconv.FormatInt(recentTimestamp, 10))}, + }, + }, + } + } + + ts.fakeTaggingClient.setResources(taggedResources) + + // Block dispose-aliases task so it does not interfere + ts.plugin.hooks.disposeAliasesSignal = make(chan error) + disposeKeysSignal := make(chan error) + ts.plugin.hooks.disposeKeysSignal = disposeKeysSignal + + _, err := ts.plugin.Configure(ctx, configureTagBasedRequest(t)) + require.NoError(t, err) + + // Consume initial (no-op) signal before Configure returns. + _ = waitForSignal(t, disposeKeysSignal) + + // Set the error after Configure so Configure itself succeeds. + ts.fakeTaggingClient.setErr(tt.getResourcesErr) + + // Advance far enough that the stale threshold (now - 2 weeks) reaches + // epoch, making keys with lastUpdate=0 eligible for disposal. + ts.clockHook.Add(keyThresholdForTagDiscovery) + // Consume the first tick (early clock time, no keys stale yet). + _ = waitForSignal(t, disposeKeysSignal) + // Second tick runs with clk.Now() at the full advance. + err = waitForSignal(t, disposeKeysSignal) + + if tt.err != "" { + require.EqualError(t, err, tt.err) + return + } + require.NoError(t, err) + + ts.fakeKMSClient.mu.RLock() + deleteCalls := ts.fakeKMSClient.scheduleKeyDeletionCalls + tagCalls := ts.fakeKMSClient.tagResourceCalls + ts.fakeKMSClient.mu.RUnlock() + + // Disposal schedules deletion synchronously, one call per stale key. + require.Len(t, deleteCalls, tt.expectDeleteCount) + + // Verify spire-active=false was set for each disposed key. + var inactiveTagCount int + for _, call := range tagCalls { + for _, tag := range call.Tags { + if tag.TagKey != nil && *tag.TagKey == tagKeyActive && + tag.TagValue != nil && *tag.TagValue == "false" { + inactiveTagCount++ + } + } + } + require.Equal(t, tt.expectInactiveTagCount, inactiveTagCount) + }) + } +} + +// TestDisposeKeysViaTagsScheduleDeletionError verifies that when scheduling a +// stale key for deletion fails, the key is left spire-active=true (not marked +// inactive) so it is retried on the next cycle. Marking it inactive first would +// drop it from the GetResources(active=true) filter without it ever being +// scheduled for deletion, and tag mode has no creation-date sweeper to reclaim +// it. +func TestDisposeKeysViaTagsScheduleDeletionError(t *testing.T) { + ts := setupTest(t) + ts.plugin.kmsClient = ts.fakeKMSClient + ts.plugin.taggingClient = ts.fakeTaggingClient + ts.plugin.serverID = validServerID + ts.plugin.trustDomain = "test.example.org" + + // Make ScheduleKeyDeletion fail so the key is not marked inactive. + ts.fakeKMSClient.setScheduleKeyDeletionErr(errors.New("schedule deletion failed")) + + staleKeyArn := fakeKeyArnPrefix + "stale-key" + ts.fakeTaggingClient.setResources([]rgtatypes.ResourceTagMapping{ + { + ResourceARN: aws.String(staleKeyArn), + Tags: []rgtatypes.Tag{ + {Key: aws.String(tagKeyServerTD), Value: aws.String("test.example.org")}, + {Key: aws.String(tagKeyServerID), Value: aws.String("other-server-id")}, + {Key: aws.String(tagKeyActive), Value: aws.String("true")}, + {Key: aws.String(tagKeyLastUpdate), Value: aws.String("0")}, + }, + }, + }) + + // Advance so staleThreshold (now - 2 weeks) reaches the Unix epoch, making + // the lastUpdate=0 key eligible for disposal. + ts.clockHook.Add(keyThresholdForTagDiscovery) + + err := ts.plugin.disposeKeysViaTags(ctx) + require.Error(t, err) + + // The key must not be marked inactive, since scheduling deletion failed. + ts.fakeKMSClient.mu.RLock() + calls := ts.fakeKMSClient.tagResourceCalls + ts.fakeKMSClient.mu.RUnlock() + for _, call := range calls { + for _, tag := range call.Tags { + if tag.TagKey != nil && *tag.TagKey == tagKeyActive { + require.Fail(t, "stale key should not be marked inactive when scheduling deletion fails") + } + } + } +} diff --git a/pkg/server/plugin/keymanager/awskms/client.go b/pkg/server/plugin/keymanager/awskms/client.go index cad325bc93..eadff1c88f 100644 --- a/pkg/server/plugin/keymanager/awskms/client.go +++ b/pkg/server/plugin/keymanager/awskms/client.go @@ -7,6 +7,7 @@ import ( "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" "github.com/aws/aws-sdk-go-v2/service/sts" ) @@ -21,6 +22,11 @@ type kmsClient interface { Sign(context.Context, *kms.SignInput, ...func(*kms.Options)) (*kms.SignOutput, error) ListKeys(context.Context, *kms.ListKeysInput, ...func(*kms.Options)) (*kms.ListKeysOutput, error) DeleteAlias(context.Context, *kms.DeleteAliasInput, ...func(*kms.Options)) (*kms.DeleteAliasOutput, error) + TagResource(context.Context, *kms.TagResourceInput, ...func(*kms.Options)) (*kms.TagResourceOutput, error) +} + +type taggingClient interface { + GetResources(context.Context, *resourcegroupstaggingapi.GetResourcesInput, ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error) } type stsClient interface { @@ -31,6 +37,10 @@ func newKMSClient(c aws.Config) (kmsClient, error) { return kms.NewFromConfig(c), nil } +func newTaggingClient(c aws.Config) (taggingClient, error) { + return resourcegroupstaggingapi.NewFromConfig(c), nil +} + func newSTSClient(c aws.Config) (stsClient, error) { return sts.NewFromConfig(c), nil } diff --git a/pkg/server/plugin/keymanager/awskms/client_fake.go b/pkg/server/plugin/keymanager/awskms/client_fake.go index 500a616518..5319fc6933 100644 --- a/pkg/server/plugin/keymanager/awskms/client_fake.go +++ b/pkg/server/plugin/keymanager/awskms/client_fake.go @@ -20,6 +20,8 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/aws/aws-sdk-go-v2/service/kms/types" + "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" + rgtatypes "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi/types" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/spiffe/spire/test/testkey" "github.com/stretchr/testify/require" @@ -28,22 +30,26 @@ import ( ) type kmsClientFake struct { - t *testing.T - store fakeStore - mu sync.RWMutex - testKeys testkey.Keys - validAliasName *regexp.Regexp - createKeyErr error - describeKeyErr error - describeKeyMalformed bool - getPublicKeyErr error - listAliasesErr error - createAliasErr error - updateAliasErr error - scheduleKeyDeletionErr error - signErr error - listKeysErr error - deleteAliasErr error + t *testing.T + store fakeStore + mu sync.RWMutex + testKeys testkey.Keys + validAliasName *regexp.Regexp + createKeyErr error + describeKeyErr error + describeKeyMalformed bool + getPublicKeyErr error + listAliasesErr error + createAliasErr error + updateAliasErr error + scheduleKeyDeletionErr error + signErr error + listKeysErr error + deleteAliasErr error + tagResourceErr error + tagResourceCalls []kms.TagResourceInput + createKeyCalls []kms.CreateKeyInput + scheduleKeyDeletionCalls []string expectedKeyPolicy *string } @@ -54,6 +60,12 @@ type stsClientFake struct { err string } +type taggingClientFake struct { + mu sync.RWMutex + resources []rgtatypes.ResourceTagMapping + err error +} + func newKMSClientFake(t *testing.T, c *clock.Mock) *kmsClientFake { return &kmsClientFake{ t: t, @@ -69,6 +81,33 @@ func newSTSClientFake() *stsClientFake { return &stsClientFake{} } +func newTaggingClientFake() *taggingClientFake { + return &taggingClientFake{} +} + +func (tc *taggingClientFake) GetResources(_ context.Context, _ *resourcegroupstaggingapi.GetResourcesInput, _ ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error) { + tc.mu.RLock() + defer tc.mu.RUnlock() + if tc.err != nil { + return nil, tc.err + } + return &resourcegroupstaggingapi.GetResourcesOutput{ + ResourceTagMappingList: tc.resources, + }, nil +} + +func (tc *taggingClientFake) setResources(resources []rgtatypes.ResourceTagMapping) { + tc.mu.Lock() + defer tc.mu.Unlock() + tc.resources = resources +} + +func (tc *taggingClientFake) setErr(err error) { + tc.mu.Lock() + defer tc.mu.Unlock() + tc.err = err +} + func (s *stsClientFake) GetCallerIdentity(context.Context, *sts.GetCallerIdentityInput, ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { if s.err != "" { return nil, errors.New(s.err) @@ -97,12 +136,14 @@ func (k *kmsClientFake) setExpectedKeyPolicy(keyPolicy *string) { } func (k *kmsClientFake) CreateKey(_ context.Context, input *kms.CreateKeyInput, _ ...func(*kms.Options)) (*kms.CreateKeyOutput, error) { - k.mu.RLock() - defer k.mu.RUnlock() + k.mu.Lock() + defer k.mu.Unlock() if k.createKeyErr != nil { return nil, k.createKeyErr } + k.createKeyCalls = append(k.createKeyCalls, *input) + switch k.expectedKeyPolicy { case nil: require.Nil(k.t, input.Policy) @@ -234,12 +275,13 @@ func (k *kmsClientFake) ListAliases(_ context.Context, input *kms.ListAliasesInp } func (k *kmsClientFake) ScheduleKeyDeletion(_ context.Context, input *kms.ScheduleKeyDeletionInput, _ ...func(*kms.Options)) (*kms.ScheduleKeyDeletionOutput, error) { - k.mu.RLock() - defer k.mu.RUnlock() + k.mu.Lock() + defer k.mu.Unlock() if k.scheduleKeyDeletionErr != nil { return nil, k.scheduleKeyDeletionErr } + k.scheduleKeyDeletionCalls = append(k.scheduleKeyDeletionCalls, *input.KeyId) k.store.DeleteKeyEntry(*input.KeyId) return &kms.ScheduleKeyDeletionOutput{}, nil @@ -473,6 +515,25 @@ func (k *kmsClientFake) setDeleteAliasErr(fakeError string) { } } +func (k *kmsClientFake) TagResource(_ context.Context, input *kms.TagResourceInput, _ ...func(*kms.Options)) (*kms.TagResourceOutput, error) { + k.mu.Lock() + defer k.mu.Unlock() + if k.tagResourceErr != nil { + return nil, k.tagResourceErr + } + call := *input + k.tagResourceCalls = append(k.tagResourceCalls, call) + return &kms.TagResourceOutput{}, nil +} + +func (k *kmsClientFake) setTagResourceErr(fakeError string) { + k.mu.Lock() + defer k.mu.Unlock() + if fakeError != "" { + k.tagResourceErr = errors.New(fakeError) + } +} + const ( fakeKeyArnPrefix = "arn:aws:kms:region:1234:key/" fakeAliasArnPrefix = "arn:aws:kms:region:1234:" diff --git a/pkg/server/plugin/keymanager/awskms/fetcher.go b/pkg/server/plugin/keymanager/awskms/fetcher.go index 504dc5f5b0..43fd476fc9 100644 --- a/pkg/server/plugin/keymanager/awskms/fetcher.go +++ b/pkg/server/plugin/keymanager/awskms/fetcher.go @@ -2,6 +2,7 @@ package awskms import ( "context" + "errors" "path" "strings" "sync" @@ -9,6 +10,9 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/aws/aws-sdk-go-v2/service/kms/types" + "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" + rgtatypes "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi/types" + "github.com/aws/smithy-go" "github.com/hashicorp/go-hclog" keymanagerv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/keymanager/v1" "golang.org/x/sync/errgroup" @@ -17,13 +21,16 @@ import ( ) type keyFetcher struct { - log hclog.Logger - kmsClient kmsClient - serverID string - trustDomain string + log hclog.Logger + kmsClient kmsClient + taggingClient taggingClient + serverID string + trustDomain string } -func (kf *keyFetcher) fetchKeyEntries(ctx context.Context) ([]*keyEntry, error) { +// fetchKeyEntriesViaAlias uses the legacy alias-based discovery method. +// This approach lists all aliases and filters by the SPIRE prefix pattern. +func (kf *keyFetcher) fetchKeyEntriesViaAlias(ctx context.Context) ([]*keyEntry, error) { var keyEntries []*keyEntry var keyEntriesMutex sync.Mutex paginator := kms.NewListAliasesPaginator(kf.kmsClient, &kms.ListAliasesInput{Limit: aws.Int32(100)}) @@ -142,3 +149,238 @@ func (kf *keyFetcher) spireKeyIDFromAlias(aliasName string) (string, bool) { } return decodeKeyID(trimmed), true } + +// fetchKeyEntriesViaTag uses AWS Resource Groups Tagging API for efficient key discovery. +// This approach filters keys by SPIRE-specific tags similar to how GCP KMS uses labels. +// Keys are filtered by: trust domain, server ID, and active status. +func (kf *keyFetcher) fetchKeyEntriesViaTag(ctx context.Context) ([]*keyEntry, error) { + var keyEntries []*keyEntry + var keyEntriesMutex sync.Mutex + g, ctx := errgroup.WithContext(ctx) + + // Build tag filters to find only keys belonging to this server + // Unlike GCP, AWS supports dots in tag values, so we use the trust domain directly + tagFilters := []rgtatypes.TagFilter{ + { + Key: aws.String(tagKeyServerTD), + Values: []string{kf.trustDomain}, + }, + { + Key: aws.String(tagKeyServerID), + Values: []string{kf.serverID}, + }, + { + Key: aws.String(tagKeyActive), + Values: []string{"true"}, + }, + } + + // Use pagination to handle large numbers of keys + paginator := resourcegroupstaggingapi.NewGetResourcesPaginator(kf.taggingClient, &resourcegroupstaggingapi.GetResourcesInput{ + ResourceTypeFilters: []string{"kms:key"}, + TagFilters: tagFilters, + }) + + for { + resourcesResp, err := paginator.NextPage(ctx) + switch { + case err != nil: + if permErr := tagGetResourcesPermissionError(err); permErr != nil { + return nil, permErr + } + return nil, status.Errorf(codes.Internal, "failed to fetch keys by tags: %v", err) + case resourcesResp == nil: + return nil, status.Error(codes.Internal, "failed to fetch keys by tags: nil response") + } + + kf.log.Debug("Found keys with SPIRE tags", "num_keys", len(resourcesResp.ResourceTagMappingList)) + + for _, resource := range resourcesResp.ResourceTagMappingList { + if resource.ResourceARN == nil { + continue + } + + keyArn := *resource.ResourceARN + + // Extract SPIRE key ID from tags + spireKeyID, ok := kf.spireKeyIDFromTags(resource.Tags) + if !ok { + kf.log.Warn("Could not get SPIRE key ID from tags", "key_arn", keyArn) + continue + } + + // Trigger a goroutine to get the details of the key + g.Go(func() error { + entry, err := kf.fetchKeyEntryDetailsFromArn(ctx, keyArn, spireKeyID) + if err != nil { + return err + } + if entry == nil { + return nil + } + + keyEntriesMutex.Lock() + keyEntries = append(keyEntries, entry) + keyEntriesMutex.Unlock() + return nil + }) + } + + if !paginator.HasMorePages() { + break + } + } + + // Wait for all the detail gathering routines to finish + if err := g.Wait(); err != nil { + statusErr := status.Convert(err) + return nil, status.Errorf(statusErr.Code(), "failed to fetch key entries: %v", statusErr.Message()) + } + + return keyEntries, nil +} + +// fetchKeyEntryDetailsFromArn retrieves key details using a key ARN directly. +// This is used for tag-based discovery where we get the ARN from the tagging API. +func (kf *keyFetcher) fetchKeyEntryDetailsFromArn(ctx context.Context, keyArn string, spireKeyID string) (*keyEntry, error) { + describeResp, err := kf.kmsClient.DescribeKey(ctx, &kms.DescribeKeyInput{KeyId: &keyArn}) + switch { + case err != nil: + return nil, status.Errorf(codes.Internal, "failed to describe key: %v", err) + case describeResp == nil || describeResp.KeyMetadata == nil: + return nil, status.Error(codes.Internal, "malformed describe key response") + case describeResp.KeyMetadata.Arn == nil: + return nil, status.Errorf(codes.Internal, "found SPIRE key without arn: %q", keyArn) + case !describeResp.KeyMetadata.Enabled: + // Key is disabled or pending deletion. This can happen when a key + // was scheduled for deletion by the alias-based disposal path, + // which does not clear SPIRE tags. Skip the key gracefully. + kf.log.Warn("Skipping disabled SPIRE key found via tags", keyArnTag, keyArn) + return nil, nil + } + + keyType, ok := keyTypeFromKeySpec(describeResp.KeyMetadata.KeySpec) + if !ok { + return nil, status.Errorf(codes.Internal, "unsupported key spec: %v", describeResp.KeyMetadata.KeySpec) + } + + publicKeyResp, err := kf.kmsClient.GetPublicKey(ctx, &kms.GetPublicKeyInput{KeyId: &keyArn}) + switch { + case err != nil: + return nil, status.Errorf(codes.Internal, "failed to get public key: %v", err) + case publicKeyResp == nil || publicKeyResp.PublicKey == nil || len(publicKeyResp.PublicKey) == 0: + return nil, status.Error(codes.Internal, "malformed get public key response") + } + + // Build the expected alias name for this key. Even though we're using tag-based discovery, + // aliases are still created for all keys (for human-readable names in AWS console). + trustDomain := sanitizeTrustDomain(kf.trustDomain) + aliasName := path.Join(aliasPrefix, trustDomain, kf.serverID, encodeKeyID(spireKeyID)) + + return &keyEntry{ + Arn: *describeResp.KeyMetadata.Arn, + AliasName: aliasName, + PublicKey: &keymanagerv1.PublicKey{ + Id: spireKeyID, + Type: keyType, + PkixData: publicKeyResp.PublicKey, + Fingerprint: makeFingerprint(publicKeyResp.PublicKey), + }, + }, nil +} + +// fetchKeyEntriesWithMigration performs tag-based discovery with automatic +// migration of pre-existing untagged keys. It fetches keys via both tags and +// aliases, then applies SPIRE tags to any alias-discovered keys that don't +// have them yet. This allows a transparent one-time migration from alias-based +// to tag-based discovery without any manual steps. lastUpdate is the +// spire-last-update tag value stamped on migrated keys so they are immediately +// eligible for staleness evaluation. +func (kf *keyFetcher) fetchKeyEntriesWithMigration(ctx context.Context, spireTags []types.Tag, lastUpdate string) ([]*keyEntry, error) { + taggedEntries, err := kf.fetchKeyEntriesViaTag(ctx) + if err != nil { + return nil, err + } + + // Also run alias-based discovery to catch pre-existing keys that were + // created before tag-based discovery was enabled. + aliasEntries, err := kf.fetchKeyEntriesViaAlias(ctx) + if err != nil { + return nil, err + } + + taggedIDs := make(map[string]bool, len(taggedEntries)) + for _, e := range taggedEntries { + taggedIDs[e.PublicKey.Id] = true + } + + var migratedCount int + for _, entry := range aliasEntries { + if taggedIDs[entry.PublicKey.Id] { + continue + } + + kf.log.Info("Applying SPIRE tags to legacy key during migration to tag-based discovery", + keyArnTag, entry.Arn) + + // Build a fresh slice to avoid mutating the shared spireTags backing array. + tags := append(append([]types.Tag(nil), spireTags...), + types.Tag{ + TagKey: aws.String(tagKeySPIREKeyID), + TagValue: aws.String(entry.PublicKey.Id), + }, + types.Tag{ + TagKey: aws.String(tagKeyLastUpdate), + TagValue: aws.String(lastUpdate), + }, + ) + if _, err := kf.kmsClient.TagResource(ctx, &kms.TagResourceInput{ + KeyId: &entry.Arn, + Tags: tags, + }); err != nil { + // Don't fail startup. The key is still usable and tagging will + // be retried on the next server restart. + kf.log.Warn("Failed to apply SPIRE tags to legacy key during migration; key will still be available", + keyArnTag, entry.Arn, reasonTag, err) + } + + migratedCount++ + taggedEntries = append(taggedEntries, entry) + } + + if migratedCount > 0 { + kf.log.Info("Tag-based key discovery migration finished", + "migrated_keys", migratedCount, "total_keys", len(taggedEntries)) + } else { + kf.log.Debug("No legacy keys required migration to tag-based discovery", + "total_keys", len(taggedEntries)) + } + + return taggedEntries, nil +} + +// spireKeyIDFromTags extracts the SPIRE key ID from a resource's tags. +func (kf *keyFetcher) spireKeyIDFromTags(tags []rgtatypes.Tag) (string, bool) { + for _, tag := range tags { + if tag.Key != nil && *tag.Key == tagKeySPIREKeyID && tag.Value != nil { + return *tag.Value, true + } + } + return "", false +} + +// tagGetResourcesPermissionError returns an actionable error when err indicates +// the identity is not authorized to call tag:GetResources. Tag-based key +// discovery relies on the Resource Groups Tagging API, whose permissions are +// identity-based and cannot be granted through the KMS key policy. Returns nil +// when err is not an access-denied error. +func tagGetResourcesPermissionError(err error) error { + var apiErr smithy.APIError + if errors.As(err, &apiErr) && apiErr.ErrorCode() == "AccessDeniedException" { + return status.Errorf(codes.FailedPrecondition, + "tag-based key discovery requires the \"tag:GetResources\" permission in an "+ + "identity-based IAM policy (it cannot be granted through the KMS key policy); "+ + "grant the permission or disable enable_tag_based_key_discovery: %v", err) + } + return nil +} diff --git a/pkg/server/plugin/keymanager/gcpkms/client_fake.go b/pkg/server/plugin/keymanager/gcpkms/client_fake.go index 1e37992566..3e2321f400 100644 --- a/pkg/server/plugin/keymanager/gcpkms/client_fake.go +++ b/pkg/server/plugin/keymanager/gcpkms/client_fake.go @@ -586,8 +586,11 @@ func (k *fakeKMSClient) GetPublicKey(_ context.Context, req *kmspb.GetPublicKeyR } if k.pemCrc32C != nil { - // Override pemCrc32C - fakeCryptoKeyVersion.publicKey.PemCrc32C = k.pemCrc32C + // Override pemCrc32C on a clone so the shared fixture public key is + // not mutated, which would corrupt it for other tests and reruns. + publicKey := proto.Clone(fakeCryptoKeyVersion.publicKey).(*kmspb.PublicKey) + publicKey.PemCrc32C = k.pemCrc32C + return publicKey, nil } return fakeCryptoKeyVersion.publicKey, nil diff --git a/pkg/server/plugin/keymanager/gcpkms/fetcher.go b/pkg/server/plugin/keymanager/gcpkms/fetcher.go index 4070f49f82..2769c3c1d1 100644 --- a/pkg/server/plugin/keymanager/gcpkms/fetcher.go +++ b/pkg/server/plugin/keymanager/gcpkms/fetcher.go @@ -8,6 +8,7 @@ import ( "sync" "cloud.google.com/go/kms/apiv1/kmspb" + "github.com/andres-erbsen/clock" "github.com/hashicorp/go-hclog" keymanagerv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/keymanager/v1" "golang.org/x/sync/errgroup" @@ -22,6 +23,7 @@ type keyFetcher struct { log hclog.Logger serverID string tdHash string + clk clock.Clock } // fetchKeyEntries requests Cloud KMS to get the list of CryptoKeys that are @@ -104,7 +106,7 @@ func (kf *keyFetcher) getKeyEntriesFromCryptoKey(ctx context.Context, cryptoKey return nil, status.Errorf(codes.Internal, "unsupported CryptoKeyVersionAlgorithm: %v", cryptoKeyVersion.Algorithm) } - pubKey, err := getPublicKeyFromCryptoKeyVersion(ctx, kf.log, kf.kmsClient, cryptoKeyVersion.Name) + pubKey, err := getPublicKeyFromCryptoKeyVersion(ctx, kf.log, kf.kmsClient, cryptoKeyVersion.Name, kf.clk) if err != nil { return nil, status.Errorf(codes.Internal, "error getting public key: %v", err) } diff --git a/pkg/server/plugin/keymanager/gcpkms/gcpkms.go b/pkg/server/plugin/keymanager/gcpkms/gcpkms.go index 0bda872c77..c48a11e022 100644 --- a/pkg/server/plugin/keymanager/gcpkms/gcpkms.go +++ b/pkg/server/plugin/keymanager/gcpkms/gcpkms.go @@ -246,6 +246,7 @@ func (p *Plugin) Configure(ctx context.Context, req *configv1.ConfigureRequest) log: p.log, serverID: serverID, tdHash: tdHashString, + clk: p.hooks.clk, } p.log.Debug("Fetching keys from Cloud KMS", "key_ring", newConfig.KeyRing) keyEntries, err := fetcher.fetchKeyEntries(ctx) @@ -463,7 +464,7 @@ func (p *Plugin) createKey(ctx context.Context, spireKeyID string, keyType keyma cryptoKeyVersionName := cryptoKey.Name + "/cryptoKeyVersions/1" log.Debug("CryptoKeyVersion version added", cryptoKeyVersionNameTag, cryptoKeyVersionName) - pubKey, err := getPublicKeyFromCryptoKeyVersion(ctx, p.log, p.kmsClient, cryptoKeyVersionName) + pubKey, err := getPublicKeyFromCryptoKeyVersion(ctx, p.log, p.kmsClient, cryptoKeyVersionName, p.hooks.clk) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get public key: %v", err) } @@ -517,7 +518,7 @@ func (p *Plugin) addCryptoKeyVersionToCachedEntry(ctx context.Context, entry key } log.Debug("CryptoKeyVersion added", cryptoKeyVersionNameTag, cryptoKeyVersion.Name) - pubKey, err := getPublicKeyFromCryptoKeyVersion(ctx, p.log, p.kmsClient, cryptoKeyVersion.Name) + pubKey, err := getPublicKeyFromCryptoKeyVersion(ctx, p.log, p.kmsClient, cryptoKeyVersion.Name, p.hooks.clk) if err != nil { return nil, fmt.Errorf("failed to get public key: %w", err) } @@ -1078,7 +1079,10 @@ func getOrCreateServerID(idPath string) (string, error) { // getPublicKeyFromCryptoKeyVersion requests Cloud KMS to get the public key // of the specified CryptoKeyVersion. -func getPublicKeyFromCryptoKeyVersion(ctx context.Context, log hclog.Logger, kmsClient cloudKeyManagementService, cryptoKeyVersionName string) ([]byte, error) { +func getPublicKeyFromCryptoKeyVersion(ctx context.Context, log hclog.Logger, kmsClient cloudKeyManagementService, cryptoKeyVersionName string, clk clock.Clock) ([]byte, error) { + backoffMin := 10 * time.Millisecond + backoffMax := time.Second + backoff := backoffMin kmsPublicKey, errGetPublicKey := kmsClient.GetPublicKey(ctx, &kmspb.GetPublicKeyRequest{Name: cryptoKeyVersionName}) attempts := 1 @@ -1114,6 +1118,10 @@ func getPublicKeyFromCryptoKeyVersion(ctx context.Context, log hclog.Logger, kms log.Warn("Could not get the public key because the CryptoKeyVersion is still being generated. Trying again.") attempts++ kmsPublicKey, errGetPublicKey = kmsClient.GetPublicKey(ctx, &kmspb.GetPublicKeyRequest{Name: cryptoKeyVersionName}) + if errGetPublicKey != nil { + backoff = min(backoff*2, backoffMax) + clk.Sleep(backoff) + } } // Perform integrity verification. diff --git a/pkg/server/plugin/keymanager/gcpkms/gcpkms_test.go b/pkg/server/plugin/keymanager/gcpkms/gcpkms_test.go index 9fc46fe792..0ff90bd160 100644 --- a/pkg/server/plugin/keymanager/gcpkms/gcpkms_test.go +++ b/pkg/server/plugin/keymanager/gcpkms/gcpkms_test.go @@ -401,6 +401,21 @@ func TestConfigure(t *testing.T) { require.Nil(t, tt.config, "The test case must define a configuration or a configuration request, not both.") configureRequest = tt.configureRequest } + // The retry loop blocks on the mock clock's Sleep. Advance the + // clock from a background goroutine by exactly the slept duration + // each time a sleep begins, until the call under test completes. + done := make(chan struct{}) + defer close(done) + go func() { + for { + select { + case <-done: + return + case d := <-ts.clockHook.SleepCh(): + ts.clockHook.Add(d) + } + } + }() _, err := ts.plugin.Configure(ctx, configureRequest) spiretest.RequireGRPCStatusContains(t, err, tt.expectCode, tt.expectMsg) @@ -1000,6 +1015,21 @@ func TestGenerateKey(t *testing.T) { ts.fakeKMSClient.setGetPublicKeySequentialErrs(tt.getPublicKeyErr, tt.getPublicKeyErrCount) + // The retry loop blocks on the mock clock's Sleep. Advance the + // clock from a background goroutine by exactly the slept duration + // each time a sleep begins, until the call under test completes. + done := make(chan struct{}) + defer close(done) + go func() { + for { + select { + case <-done: + return + case d := <-ts.clockHook.SleepCh(): + ts.clockHook.Add(d) + } + } + }() resp, err := ts.plugin.GenerateKey(ctx, tt.generateKeyReq) if tt.expectMsg != "" { spiretest.RequireGRPCStatusContains(t, err, tt.expectCode, tt.expectMsg) @@ -1232,7 +1262,7 @@ func TestGetPublicKeys(t *testing.T) { for _, fakeKey := range storedFakeCryptoKeys { storedFakeCryptoKeyVersions := fakeKey.fetchFakeCryptoKeyVersions() for _, fakeKeyVersion := range storedFakeCryptoKeyVersions { - pubKey, err := getPublicKeyFromCryptoKeyVersion(ctx, ts.plugin.log, ts.fakeKMSClient, fakeKeyVersion.CryptoKeyVersion.Name) + pubKey, err := getPublicKeyFromCryptoKeyVersion(ctx, ts.plugin.log, ts.fakeKMSClient, fakeKeyVersion.CryptoKeyVersion.Name, ts.plugin.hooks.clk) require.NoError(t, err) require.Equal(t, pubKey, resp.PublicKeys[0].PkixData) } diff --git a/pkg/server/plugin/nodeattestor/azureimds/client.go b/pkg/server/plugin/nodeattestor/azureimds/client.go index 0b87d4cbf5..2f20001935 100644 --- a/pkg/server/plugin/nodeattestor/azureimds/client.go +++ b/pkg/server/plugin/nodeattestor/azureimds/client.go @@ -175,10 +175,16 @@ func (c *azureClient) getVMSSInfo(ctx context.Context, subscriptionIDs []*string } func (c *azureClient) getNetworkInterfaces(ctx context.Context, vmId string, subscriptionId *string) ([]*NetworkInterface, error) { + // Azure Resource Graph lowercases the resourceGroup segment of the id + // column on the resources table, but nested property values such as + // properties.virtualMachine.id keep their original casing. Compare with + // tolower on both sides so a standalone VM whose NIC records a + // differently-cased VM id still matches. See: + // https://learn.microsoft.com/en-us/azure/governance/resource-graph/concepts/explore-resources query := fmt.Sprintf(` Resources | where type == "microsoft.network/networkinterfaces" - | where properties.virtualMachine.id == "%s" + | where tolower(properties.virtualMachine.id) == tolower("%s") | mv-expand ipConfig = properties.ipConfigurations | extend subnetId = tostring(ipConfig.properties.subnet.id) | extend vnetName = extract(@"virtualNetworks/([^/]+)", 1, subnetId) @@ -188,10 +194,10 @@ func (c *azureClient) getNetworkInterfaces(ctx context.Context, vmId string, sub | extend nsgRg = extract(@"resourceGroups/([^/]+)",1,nsgId) | extend nsgName = extract(@"networkSecurityGroups/([^/]+)",1,nsgId) | extend securityGroup = bag_pack("resourceGroup", nsgRg, "name",nsgName) - | summarize + | summarize subnets = make_list(subnetObj) - by id, name, resourceGroup, tostring(securityGroup) - | project name, resourceGroup, subnets, securityGroup`, vmId) + by id, name, resourceGroup, securityGroup_str = tostring(securityGroup) + | project name, resourceGroup, subnets, securityGroup = todynamic(securityGroup_str)`, vmId) options := &armresourcegraph.QueryRequestOptions{ ResultFormat: new(armresourcegraph.ResultFormatObjectArray), } diff --git a/pkg/server/plugin/nodeattestor/azureimds/client_test.go b/pkg/server/plugin/nodeattestor/azureimds/client_test.go index c4cf1997d9..227b731a72 100644 --- a/pkg/server/plugin/nodeattestor/azureimds/client_test.go +++ b/pkg/server/plugin/nodeattestor/azureimds/client_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" "github.com/stretchr/testify/require" ) @@ -197,3 +198,94 @@ func TestBuildVirtualMachineFromVMSSInstanceNoNetworkProfile(t *testing.T) { require.Equal(t, "rg", vm.ResourceGroup) require.Empty(t, vm.Interfaces) } + +// TestExtractArmResourceGraphItemsNetworkInterface locks in the shape the +// standalone-VM NIC query must return. The Resource Graph response must carry +// securityGroup as an object so it unmarshals into SecurityGroup; a string +// value (the pre-fix behavior) fails to unmarshal and breaks attestation. +func TestExtractArmResourceGraphItemsNetworkInterface(t *testing.T) { + tests := []struct { + name string + totalRecord int64 + data any + expected []*NetworkInterface + expectErr string + }{ + { + name: "security group as object unmarshals", + totalRecord: 1, + data: []any{ + map[string]any{ + "name": "nic-1", + "resourceGroup": "rg-1", + "securityGroup": map[string]any{ + "resourceGroup": "nsg-rg", + "name": "nsg-1", + }, + "subnets": []any{ + map[string]any{"vnet": "vnet-1", "name": "subnet-1"}, + }, + }, + }, + expected: []*NetworkInterface{ + { + Name: "nic-1", + SecurityGroup: SecurityGroup{ResourceGroup: "nsg-rg", Name: "nsg-1"}, + Subnets: []Subnet{{VNet: "vnet-1", SubnetName: "subnet-1"}}, + }, + }, + }, + { + name: "empty security group object unmarshals", + totalRecord: 1, + data: []any{ + map[string]any{ + "name": "nic-1", + "resourceGroup": "rg-1", + "securityGroup": map[string]any{"resourceGroup": "", "name": ""}, + }, + }, + expected: []*NetworkInterface{ + {Name: "nic-1", SecurityGroup: SecurityGroup{}}, + }, + }, + { + name: "security group as string fails to unmarshal", + totalRecord: 1, + data: []any{ + map[string]any{ + "name": "nic-1", + "resourceGroup": "rg-1", + "securityGroup": `{"resourceGroup":"nsg-rg","name":"nsg-1"}`, + }, + }, + expectErr: "unable to unmarshal item", + }, + { + name: "no records returns not found", + totalRecord: 0, + data: []any{}, + expectErr: "resource not found", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resp := armresourcegraph.ClientResourcesResponse{ + QueryResponse: armresourcegraph.QueryResponse{ + TotalRecords: &tc.totalRecord, + Data: tc.data, + }, + } + + items, err := extractArmResourceGraphItems[NetworkInterface](resp) + if tc.expectErr != "" { + require.ErrorContains(t, err, tc.expectErr) + require.Nil(t, items) + return + } + require.NoError(t, err) + require.Equal(t, tc.expected, items) + }) + } +} diff --git a/pkg/server/plugin/nodeattestor/azureimds/doc_validation.go b/pkg/server/plugin/nodeattestor/azureimds/doc_validation.go index 667cda7021..ce5aa3f5e1 100644 --- a/pkg/server/plugin/nodeattestor/azureimds/doc_validation.go +++ b/pkg/server/plugin/nodeattestor/azureimds/doc_validation.go @@ -11,31 +11,23 @@ import ( "io" "net/http" "net/url" - "regexp" + "strings" "time" "github.com/smallstep/pkcs7" "github.com/spiffe/spire/pkg/common/plugin/azure" ) -// Azure-specific certificate validation constants -var ( - // Expected subject patterns for Azure certificates - AzureMetadataSubject = regexp.MustCompile(`^metadata\.azure\.com$`) - - // Expected issuer patterns - MicrosoftAzureRSATLSIssuer = regexp.MustCompile(`^Microsoft Azure RSA TLS Issuing CA \d{2}$`) - // The azure Docs state that it should be DigiCert Global Root CA, but it is actually DigiCert Global Root G2 which is the newer version - DigiCertGlobalRootCA = regexp.MustCompile(`^DigiCert Global Root G2$`) -) - const ( - // expected microsoft issuer host + // Default metadata domain for commercial Azure + DefaultMetadataDomain = "metadata.azure.com" + + // Expected microsoft issuer host for intermediate certificate fetching MicrosoftIntermediateIssuerHost = "www.microsoft.com" ) // ValidateAttestedDocument validates the Azure IMDS attested document signature -func validateAttestedDocument(ctx context.Context, doc *azure.AttestedDocument) (*azure.AttestedDocumentContent, error) { +func validateAttestedDocument(ctx context.Context, doc *azure.AttestedDocument, allowedMetadataDomains []string) (*azure.AttestedDocumentContent, error) { if doc.Signature == "" { return nil, errors.New("missing signature in attested document") } @@ -83,8 +75,8 @@ func validateAttestedDocument(ctx context.Context, doc *azure.AttestedDocument) } // Step 7: Perform Azure-specific certificate validation - if err := validateAzureCertificates(signingCert, intermediateCert); err != nil { - return nil, fmt.Errorf("azure certificate validation failed: %w", err) + if err := validateAzureCertificate(signingCert, allowedMetadataDomains); err != nil { + return nil, fmt.Errorf("signing certificate validation failed: %w", err) } // Step 8: Validate certificate chain @@ -152,59 +144,28 @@ func getIntermediateCertificate(ctx context.Context, signingCert *x509.Certifica return cert, nil } -// validateAzureCertificates performs Azure-specific certificate validation -func validateAzureCertificates(signingCert, intermediateCert *x509.Certificate) error { - // Validate signing certificate subject - if err := validateCertificateSubject(signingCert, AzureMetadataSubject); err != nil { - return fmt.Errorf("signing certificate subject validation failed: %w", err) - } - - // Validate signing certificate issuer - if err := validateCertificateIssuer(signingCert, MicrosoftAzureRSATLSIssuer); err != nil { - return fmt.Errorf("signing certificate issuer validation failed: %w", err) - } - - if intermediateCert != nil { - // Validate intermediate certificate issuer - if err := validateCertificateIssuer(intermediateCert, DigiCertGlobalRootCA); err != nil { - return fmt.Errorf("intermediate certificate issuer validation failed: %w", err) - } +// validateAzureCertificate performs Azure-specific certificate validation. +// Following Azure's recommendation to validate the certificate Subject Alternative Name (SAN) +// See: https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service?tabs=linux#signature-validation-guidance +func validateAzureCertificate(cert *x509.Certificate, baseDomains []string) error { + // Check SAN DNS names against each allowed base domain + for _, dnsName := range cert.DNSNames { + for _, baseDomain := range baseDomains { + // Accept exact match with base domain + if dnsName == baseDomain { + return nil + } - // Validate intermediate certificate subject - if err := validateCertificateSubject(intermediateCert, MicrosoftAzureRSATLSIssuer); err != nil { - return fmt.Errorf("intermediate certificate subject validation failed: %w", err) + // Accept any subdomain under the base domain (e.g., eastus.metadata.azure.com, sub.eastus.metadata.azure.com) + suffix := "." + baseDomain + if strings.HasSuffix(dnsName, suffix) { + return nil + } } } - return nil -} - -// validateCertificateSubject validates that the certificate subject matches the expected regex pattern -func validateCertificateSubject(cert *x509.Certificate, expectedSubject *regexp.Regexp) error { - subject := cert.Subject.CommonName - if subject == "" { - return errors.New("certificate has no common name in subject") - } - - if !expectedSubject.MatchString(subject) { - return fmt.Errorf("certificate subject %q does not match expected pattern %q", subject, expectedSubject.String()) - } - - return nil -} - -// validateCertificateIssuer validates that the certificate issuer matches the expected regex pattern -func validateCertificateIssuer(cert *x509.Certificate, expectedIssuer *regexp.Regexp) error { - issuer := cert.Issuer.CommonName - if issuer == "" { - return errors.New("certificate has no common name in issuer") - } - - if !expectedIssuer.MatchString(issuer) { - return fmt.Errorf("certificate issuer %q does not match expected pattern %q", issuer, expectedIssuer.String()) - } - - return nil + return fmt.Errorf("certificate does not have any valid domain in SAN (found SANs=%v, allowed domains=%v)", + cert.DNSNames, baseDomains) } // validateCertificateChain validates the certificate chain against the DigiCert Global Root CA @@ -221,8 +182,10 @@ func validateCertificateChain(signingCert, intermediateCert *x509.Certificate) e } } rootCerts := x509.NewCertPool() - for _, c := range roots { - rootCerts.AppendCertsFromPEM([]byte(c)) + for i, c := range roots { + if !rootCerts.AppendCertsFromPEM([]byte(c)) { + return fmt.Errorf("failed to parse root certificate at index %d", i) + } } opts := x509.VerifyOptions{ Intermediates: intermediates, diff --git a/pkg/server/plugin/nodeattestor/azureimds/doc_validation_test.go b/pkg/server/plugin/nodeattestor/azureimds/doc_validation_test.go index c32dafe3ff..5a11e06405 100644 --- a/pkg/server/plugin/nodeattestor/azureimds/doc_validation_test.go +++ b/pkg/server/plugin/nodeattestor/azureimds/doc_validation_test.go @@ -10,7 +10,6 @@ import ( "io" "math/big" "net/http" - "regexp" "testing" "time" @@ -88,279 +87,193 @@ func TestExtractIssuerURL(t *testing.T) { } } -func TestValidateCertificateSubject(t *testing.T) { +func TestGetIntermediateCertificate(t *testing.T) { tests := []struct { name string cert *x509.Certificate - expected *regexp.Regexp expectErr bool errorContains string }{ { - name: "valid subject matching expected value", - cert: createTestCert(t, &x509.Certificate{ - Subject: pkix.Name{CommonName: "metadata.azure.com"}, - }), - expected: AzureMetadataSubject, - expectErr: false, - }, - { - name: "empty CommonName in subject", + name: "wrong host in CA Issuers URL", cert: createTestCert(t, &x509.Certificate{ - Subject: pkix.Name{CommonName: ""}, + IssuingCertificateURL: []string{"https://example.com/cert.crt"}, }), - expected: AzureMetadataSubject, expectErr: true, - errorContains: "certificate has no common name in subject", + errorContains: "CA Issuers URL host", }, { - name: "subject mismatch", + name: "no CA Issuers URL", cert: createTestCert(t, &x509.Certificate{ - Subject: pkix.Name{CommonName: "wrong.subject.com"}, + IssuingCertificateURL: []string{}, }), - expected: AzureMetadataSubject, expectErr: true, - errorContains: "certificate subject", + errorContains: "failed to extract CA Issuers URL", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validateCertificateSubject(tt.cert, tt.expected) + ctx := context.Background() + cert, err := getIntermediateCertificate(ctx, tt.cert) if tt.expectErr { require.Error(t, err) require.Contains(t, err.Error(), tt.errorContains) + require.Nil(t, cert) } else { require.NoError(t, err) + require.NotNil(t, cert) } }) } } -func TestValidateCertificateIssuer(t *testing.T) { +func TestValidateAzureCertificates(t *testing.T) { + allowedMetadataDomains := []string{DefaultMetadataDomain} + tests := []struct { name string - cert *x509.Certificate - expected *regexp.Regexp + signingCert *x509.Certificate + domains []string expectErr bool errorContains string }{ { - name: "valid issuer with numbered CA 03", - cert: createTestCertWithIssuer(t, &x509.Certificate{}, "Microsoft Azure RSA TLS Issuing CA 03"), - expected: MicrosoftAzureRSATLSIssuer, + name: "valid signing certificate with single-level subdomain SAN", + signingCert: createTestCertWithIssuer(t, &x509.Certificate{ + Subject: pkix.Name{CommonName: "metadata.azure.com"}, + DNSNames: []string{"eastus.metadata.azure.com"}, + }, "Microsoft Azure RSA TLS Issuing CA 03"), + domains: allowedMetadataDomains, expectErr: false, }, { - name: "valid issuer with numbered CA 07", - cert: createTestCertWithIssuer(t, &x509.Certificate{}, "Microsoft Azure RSA TLS Issuing CA 07"), - expected: MicrosoftAzureRSATLSIssuer, + name: "valid signing certificate with exact domain match", + signingCert: createTestCertWithIssuer(t, &x509.Certificate{ + Subject: pkix.Name{CommonName: "metadata.azure.com"}, + DNSNames: []string{"metadata.azure.com"}, + }, "Microsoft Azure RSA TLS Issuing CA 03"), + domains: allowedMetadataDomains, expectErr: false, }, { - name: "valid issuer with numbered CA 08", - cert: createTestCertWithIssuer(t, &x509.Certificate{}, "Microsoft Azure RSA TLS Issuing CA 08"), - expected: MicrosoftAzureRSATLSIssuer, + name: "valid signing certificate with different regional SAN", + signingCert: createTestCertWithIssuer(t, &x509.Certificate{ + Subject: pkix.Name{CommonName: "metadata.azure.com"}, + DNSNames: []string{"centralus.metadata.azure.com"}, + }, "Microsoft Azure RSA TLS Issuing CA 03"), + domains: allowedMetadataDomains, expectErr: false, }, { - name: "valid issuer with numbered CA 04", - cert: createTestCertWithIssuer(t, &x509.Certificate{}, "Microsoft Azure RSA TLS Issuing CA 04"), - expected: MicrosoftAzureRSATLSIssuer, + name: "works with any issuer name - G2 issuer", + signingCert: createTestCertWithIssuer(t, &x509.Certificate{ + Subject: pkix.Name{CommonName: "metadata.azure.com"}, + DNSNames: []string{"westus2.metadata.azure.com"}, + }, "Microsoft TLS G2 RSA CA OCSP 10"), + domains: allowedMetadataDomains, expectErr: false, }, { - name: "empty CommonName in issuer", - cert: createTestCert(t, &x509.Certificate{ - Issuer: pkix.Name{CommonName: ""}, - }), - expected: MicrosoftAzureRSATLSIssuer, - expectErr: true, - errorContains: "certificate has no common name in issuer", - }, - { - name: "issuer mismatch", - cert: createTestCertWithIssuer(t, &x509.Certificate{}, "Wrong Issuer"), - expected: MicrosoftAzureRSATLSIssuer, - expectErr: true, - errorContains: "certificate issuer", - }, - { - name: "base string without number should fail", - cert: createTestCertWithIssuer(t, &x509.Certificate{}, "Microsoft Azure RSA TLS Issuing CA"), - expected: MicrosoftAzureRSATLSIssuer, - expectErr: true, - errorContains: "certificate issuer", - }, - { - name: "invalid numbered format - single digit", - cert: createTestCertWithIssuer(t, &x509.Certificate{}, "Microsoft Azure RSA TLS Issuing CA 1"), - expected: MicrosoftAzureRSATLSIssuer, - expectErr: true, - errorContains: "certificate issuer", - }, - { - name: "invalid numbered format - three digits", - cert: createTestCertWithIssuer(t, &x509.Certificate{}, "Microsoft Azure RSA TLS Issuing CA 123"), - expected: MicrosoftAzureRSATLSIssuer, - expectErr: true, - errorContains: "certificate issuer", - }, - { - name: "invalid numbered format - non-numeric suffix", - cert: createTestCertWithIssuer(t, &x509.Certificate{}, "Microsoft Azure RSA TLS Issuing CA abc"), - expected: MicrosoftAzureRSATLSIssuer, - expectErr: true, - errorContains: "certificate issuer", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateCertificateIssuer(tt.cert, tt.expected) - - if tt.expectErr { - require.Error(t, err) - require.Contains(t, err.Error(), tt.errorContains) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestGetIntermediateCertificate(t *testing.T) { - tests := []struct { - name string - cert *x509.Certificate - expectErr bool - errorContains string - }{ - { - name: "wrong host in CA Issuers URL", - cert: createTestCert(t, &x509.Certificate{ - IssuingCertificateURL: []string{"https://example.com/cert.crt"}, - }), + name: "missing SAN should fail", + signingCert: createTestCertWithIssuer(t, &x509.Certificate{ + Subject: pkix.Name{CommonName: "metadata.azure.com"}, + DNSNames: nil, + }, "Microsoft Azure RSA TLS Issuing CA 03"), + domains: allowedMetadataDomains, expectErr: true, - errorContains: "CA Issuers URL host", + errorContains: "certificate does not have any valid domain in SAN", }, { - name: "no CA Issuers URL", - cert: createTestCert(t, &x509.Certificate{ - IssuingCertificateURL: []string{}, - }), + name: "wrong SAN domain should fail", + signingCert: createTestCertWithIssuer(t, &x509.Certificate{ + Subject: pkix.Name{CommonName: "metadata.azure.com"}, + DNSNames: []string{"wrong.domain.com"}, + }, "Microsoft Azure RSA TLS Issuing CA 03"), + domains: allowedMetadataDomains, expectErr: true, - errorContains: "failed to extract CA Issuers URL", + errorContains: "certificate does not have any valid domain in SAN", }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := context.Background() - cert, err := getIntermediateCertificate(ctx, tt.cert) - - if tt.expectErr { - require.Error(t, err) - require.Contains(t, err.Error(), tt.errorContains) - require.Nil(t, cert) - } else { - require.NoError(t, err) - require.NotNil(t, cert) - } - }) - } -} - -func TestValidateAzureCertificates(t *testing.T) { - tests := []struct { - name string - signingCert *x509.Certificate - intermediateCert *x509.Certificate - expectErr bool - errorContains string - }{ { - name: "valid signing and intermediate certificates", + name: "multi-level subdomain should succeed", signingCert: createTestCertWithIssuer(t, &x509.Certificate{ - Subject: pkix.Name{CommonName: "metadata.azure.com"}, + Subject: pkix.Name{CommonName: "metadata.azure.com"}, + DNSNames: []string{"sub.eastus.metadata.azure.com"}, }, "Microsoft Azure RSA TLS Issuing CA 03"), - intermediateCert: createTestCertWithIssuer(t, &x509.Certificate{ - Subject: pkix.Name{CommonName: "Microsoft Azure RSA TLS Issuing CA 03"}, - }, "DigiCert Global Root G2"), + domains: allowedMetadataDomains, expectErr: false, }, { - name: "nil intermediate certificate - should still validate signing cert", + name: "multiple SANs with one matching domain", signingCert: createTestCertWithIssuer(t, &x509.Certificate{ - Subject: pkix.Name{CommonName: "metadata.azure.com"}, + Subject: pkix.Name{CommonName: "other.domain.com"}, + DNSNames: []string{"other.domain.com", "westus.metadata.azure.com", "another.domain.com"}, }, "Microsoft Azure RSA TLS Issuing CA 03"), - intermediateCert: nil, - expectErr: false, + domains: allowedMetadataDomains, + expectErr: false, }, { - name: "valid signing certificate with numbered CA 03", + name: "government cloud domain", signingCert: createTestCertWithIssuer(t, &x509.Certificate{ - Subject: pkix.Name{CommonName: "metadata.azure.com"}, + Subject: pkix.Name{CommonName: "metadata.azure.us"}, + DNSNames: []string{"usgovvirginia.metadata.azure.us"}, }, "Microsoft Azure RSA TLS Issuing CA 03"), - intermediateCert: nil, - expectErr: false, + domains: []string{"metadata.azure.us"}, + expectErr: false, }, { - name: "valid intermediate certificate with numbered CA 07", + name: "multiple allowed domains - matches first domain", signingCert: createTestCertWithIssuer(t, &x509.Certificate{ - Subject: pkix.Name{CommonName: "metadata.azure.com"}, - }, "Microsoft Azure RSA TLS Issuing CA 07"), - intermediateCert: createTestCertWithIssuer(t, &x509.Certificate{ - Subject: pkix.Name{CommonName: "Microsoft Azure RSA TLS Issuing CA 07"}, - }, "DigiCert Global Root G2"), + Subject: pkix.Name{CommonName: "metadata.azure.com"}, + DNSNames: []string{"eastus.metadata.azure.com"}, + }, "Microsoft Azure RSA TLS Issuing CA 03"), + domains: []string{"metadata.azure.com", "metadata.azure.us"}, expectErr: false, }, { - name: "invalid signing certificate subject", + name: "multiple allowed domains - matches second domain", signingCert: createTestCertWithIssuer(t, &x509.Certificate{ - Subject: pkix.Name{CommonName: "wrong.subject.com"}, + Subject: pkix.Name{CommonName: "metadata.azure.us"}, + DNSNames: []string{"usgovvirginia.metadata.azure.us"}, }, "Microsoft Azure RSA TLS Issuing CA 03"), - intermediateCert: nil, - expectErr: true, - errorContains: "signing certificate subject validation failed", + domains: []string{"metadata.azure.com", "metadata.azure.us"}, + expectErr: false, }, { - name: "invalid signing certificate issuer", + name: "multiple allowed domains - matches none", signingCert: createTestCertWithIssuer(t, &x509.Certificate{ - Subject: pkix.Name{CommonName: "metadata.azure.com"}, - }, "Wrong Issuer"), - intermediateCert: nil, - expectErr: true, - errorContains: "signing certificate issuer validation failed", + Subject: pkix.Name{CommonName: "metadata.azure.cn"}, + DNSNames: []string{"chinaeast.metadata.azure.cn"}, + }, "Microsoft Azure RSA TLS Issuing CA 03"), + domains: []string{"metadata.azure.com", "metadata.azure.us"}, + expectErr: true, + errorContains: "certificate does not have any valid domain in SAN", }, { - name: "invalid intermediate certificate issuer", + name: "three-level subdomain should succeed", signingCert: createTestCertWithIssuer(t, &x509.Certificate{ - Subject: pkix.Name{CommonName: "metadata.azure.com"}, + Subject: pkix.Name{CommonName: "metadata.azure.com"}, + DNSNames: []string{"a.b.c.metadata.azure.com"}, }, "Microsoft Azure RSA TLS Issuing CA 03"), - intermediateCert: createTestCertWithIssuer(t, &x509.Certificate{ - Subject: pkix.Name{CommonName: "Microsoft Azure RSA TLS Issuing CA 03"}, - }, "Wrong Root CA"), - expectErr: true, - errorContains: "intermediate certificate issuer validation failed", + domains: allowedMetadataDomains, + expectErr: false, }, { - name: "invalid intermediate certificate subject", + name: "subdomain not matching base domain should fail", signingCert: createTestCertWithIssuer(t, &x509.Certificate{ - Subject: pkix.Name{CommonName: "metadata.azure.com"}, + Subject: pkix.Name{CommonName: "metadata.azure.com"}, + DNSNames: []string{"metadata.azure.com.attacker.com"}, }, "Microsoft Azure RSA TLS Issuing CA 03"), - intermediateCert: createTestCertWithIssuer(t, &x509.Certificate{ - Subject: pkix.Name{CommonName: "Wrong Intermediate"}, - }, "DigiCert Global Root G2"), + domains: allowedMetadataDomains, expectErr: true, - errorContains: "intermediate certificate subject validation failed", + errorContains: "certificate does not have any valid domain in SAN", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validateAzureCertificates(tt.signingCert, tt.intermediateCert) + err := validateAzureCertificate(tt.signingCert, tt.domains) if tt.expectErr { require.Error(t, err) @@ -568,7 +481,8 @@ func TestValidateAttestedDocument(t *testing.T) { doc := tt.setupDoc() ctx := context.Background() - content, err := validateAttestedDocument(ctx, doc) + allowedMetadataDomains := []string{DefaultMetadataDomain} + content, err := validateAttestedDocument(ctx, doc, allowedMetadataDomains) if tt.expectErr { require.Error(t, err) @@ -724,9 +638,9 @@ func TestValidateAttestedDocumentRejectsContentSignedByNonAzureCertificate(t *te content, err := validateAttestedDocument(context.Background(), &azure.AttestedDocument{ Encoding: "pkcs7-signature", Signature: base64.StdEncoding.EncodeToString(signature), - }) + }, []string{DefaultMetadataDomain}) require.Error(t, err) - require.Contains(t, err.Error(), "azure certificate validation failed") + require.Contains(t, err.Error(), "signing certificate validation failed") require.Nil(t, content) } diff --git a/pkg/server/plugin/nodeattestor/azureimds/imds.go b/pkg/server/plugin/nodeattestor/azureimds/imds.go index 20b99d8834..b4def2615a 100644 --- a/pkg/server/plugin/nodeattestor/azureimds/imds.go +++ b/pkg/server/plugin/nodeattestor/azureimds/imds.go @@ -58,8 +58,9 @@ type TenantConfig struct { } type IMDSAttestorConfig struct { - Tenants map[string]*TenantConfig `hcl:"tenants" json:"tenants"` - AgentPathTemplate string `hcl:"agent_path_template" json:"agent_path_template"` + Tenants map[string]*TenantConfig `hcl:"tenants" json:"tenants"` + AgentPathTemplate string `hcl:"agent_path_template" json:"agent_path_template"` + AllowedMetadataDomains []string `hcl:"allowed_metadata_domains" json:"allowed_metadata_domains"` } type tenantConfig struct { @@ -69,9 +70,10 @@ type tenantConfig struct { } type imdsAttestorConfig struct { - td spiffeid.TrustDomain - tenants map[string]*tenantConfig - idPathTemplate *agentpathtemplate.Template + td spiffeid.TrustDomain + tenants map[string]*tenantConfig + idPathTemplate *agentpathtemplate.Template + allowedMetadataDomains []string } func (t *tenantConfig) subscriptionAllowed(subscriptionID string) bool { @@ -195,10 +197,16 @@ func (p *IMDSAttestorPlugin) buildConfig(coreConfig catalog.CoreConfig, hclText } } + allowedMetadataDomains := newConfig.AllowedMetadataDomains + if len(allowedMetadataDomains) == 0 { + allowedMetadataDomains = []string{DefaultMetadataDomain} + } + return &imdsAttestorConfig{ - td: coreConfig.TrustDomain, - tenants: tenants, - idPathTemplate: tmpl, + td: coreConfig.TrustDomain, + tenants: tenants, + idPathTemplate: tmpl, + allowedMetadataDomains: allowedMetadataDomains, } } @@ -216,7 +224,7 @@ type IMDSAttestorPlugin struct { tenantIdMap map[string]string newClient func(azcore.TokenCredential) (apiClient, error) fetchCredential func(string) (azcore.TokenCredential, error) - validateAttestedDoc func(context.Context, *azure.AttestedDocument) (*azure.AttestedDocumentContent, error) + validateAttestedDoc func(context.Context, *azure.AttestedDocument, []string) (*azure.AttestedDocumentContent, error) lookupTenantID func(string) (string, error) } } @@ -288,7 +296,7 @@ func (p *IMDSAttestorPlugin) Attest(stream nodeattestorv1.NodeAttestor_AttestSer } // parse the document - docData, err := p.hooks.validateAttestedDoc(stream.Context(), &attestationData.Document) + docData, err := p.hooks.validateAttestedDoc(stream.Context(), &attestationData.Document, config.allowedMetadataDomains) if err != nil { return status.Errorf(codes.InvalidArgument, "failed to validate attested document: %v", err) } diff --git a/pkg/server/plugin/nodeattestor/azureimds/imds_test.go b/pkg/server/plugin/nodeattestor/azureimds/imds_test.go index d411681e51..97f94c1a17 100644 --- a/pkg/server/plugin/nodeattestor/azureimds/imds_test.go +++ b/pkg/server/plugin/nodeattestor/azureimds/imds_test.go @@ -794,7 +794,7 @@ func (s *IMDSAttestorSuite) loadPluginWithChallengeHandler( } return domain, nil } - attestor.hooks.validateAttestedDoc = func(ctx context.Context, doc *azure.AttestedDocument) (*azure.AttestedDocumentContent, error) { + attestor.hooks.validateAttestedDoc = func(ctx context.Context, doc *azure.AttestedDocument, allowedMetadataDomains []string) (*azure.AttestedDocumentContent, error) { s.lastValidatedDoc = doc var ( content *azure.AttestedDocumentContent diff --git a/pkg/server/plugin/nodeattestor/azureimds/roots.go b/pkg/server/plugin/nodeattestor/azureimds/roots.go index 4f99d6f96c..d013d1895e 100644 --- a/pkg/server/plugin/nodeattestor/azureimds/roots.go +++ b/pkg/server/plugin/nodeattestor/azureimds/roots.go @@ -54,7 +54,86 @@ hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= +-----END CERTIFICATE-----` + + // Microsoft TLS RSA Root G2 (self-signed) + // Valid until: 10/Apr/2040 + // downloaded from https://www.microsoft.com/pkiops/certs/Microsoft%20TLS%20RSA%20Root%20G2.crt + MicrosoftTLSRSARootG2SelfSignedPem = `-----BEGIN CERTIFICATE----- +MIIFgDCCA2igAwIBAgIQZIbjsmkYD79AQDkuLlNLmzANBgkqhkiG9w0BAQwFADBR +MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSIw +IAYDVQQDExlNaWNyb3NvZnQgVExTIFJTQSBSb290IEcyMB4XDTI1MDQxMDE4MzYz +MFoXDTQwMDQxMDE4NDM1MVowUTELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jv +c29mdCBDb3Jwb3JhdGlvbjEiMCAGA1UEAxMZTWljcm9zb2Z0IFRMUyBSU0EgUm9v +dCBHMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAN/qi59H4SgQca9B +1hnbklfea6vmLt6tOmCDtxOYrFu5Mgt7659yJ2tdQKu+GAaTsg3SEu24dPV/L+qo +F5T2MxZXit4gfCHdt9qs3k5ksFrNS3Xz7wJkTe1S2qOPBOiR+Nf7Pxn9TkU5Xugp +w9byZ6+zAJTBjeHwShiYOfrACYQzQQUDBav0deFTpSGSXERM5mBXpJACl8fV2JME +MkrthVHt1sW6oD82ii9vTDmoBGkihDt1FPfZPrSp0AdBOIF+Rkgd6BnmcLigqhjd +cr9+fbVsThlwEYQEE4HUYv2ca/Ec3f/sp+Dmx40xS7/yazPM0Yy2/rEbo0qoXw5/ +MXY4PwdHbAGorKUU2oKwXx9svIkawBIqy3uXHyguMRaKU0x1Zvl9AdbCYKpljzGe +csvMmy3lRBmTV6tujsLZLmWwci3nocOhfkkdWWfWCdQYEm3AlUV4yfFgauT27gYk +O3NKWhHCPpdhwHju7xOnMOJKOC3uWBcTILc2Kt8LMrTCOLGR3RKwRbZ8wLEdYqGd +3XESFZYBnQoJ9bHds9ngOEI09mm2w68Wc/7UQ/hD5Fj5T7Pz7NY9lDpAOBWrDIbQ +ZM4aY+iElFUJWrYEttE+mhXHZBGA0BuCDk3/RZ6yZQclH9uayMbs1luFUASfHMtd +IIJnnnni3+jy6G/EIHIwhuWddSc1AgMBAAGjVDBSMA4GA1UdDwEB/wQEAwIBhjAP +BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTekYZIt6ExWTHxS18HqdyIedqodjAQ +BgkrBgEEAYI3FQEEAwIBADANBgkqhkiG9w0BAQwFAAOCAgEAdDOaUHmugVYlz5tv +URHeVRKQk2wz4C4kDF5kYjRONhm1oBTztcZ406PtsDsKXpI+chsuCoPev5+o0zmo +DtgjNQcFj7bzxhrUQyQXk5obQPB53cnAD3MaBYeLCrrbJIi0hL3N6n8b7XalNAe6 +bg7qczpIwu3xnX/3jgUT28vTfShczLK+oHkQKibrcuSmoZcedsQ8flaa8+Y9z+LV +BBlqPNc45eO7HgUP3UgrfDmd6U9Nd6i9SSif57koWzb0cyCCVhbVwVpNxO37B8xo +N67DdFOrlygAA0aE0do5nk8QSj+ZQis1qDmVt36BPmyv/byprsCSTMI0JTsXp5uL +QAoj8orWuAA/Ul5Wu3nri4n4JIngzeYO+s8ISGbNypagtcsvb0QT5sY4nvEEkPET +SLVIQij9/4FIYlFVlJ9nSV9sSP7H0e2RoLQbttBruxNgoKifkv0zVcyBTzPjmkej +xTOZnDVvfB+kS189DzljD/otUI6sGZT9v5fZjljMgGlAZzi2xhfrHKqvhFZSn2Al +MnuQZvLjlzEiIsG1Oj2JFOG0kGq1MmKwljhVBvuKryqDTHrqGa3E9JQattAXUoet ++QzlJ5xIPcay1LE+MA+TM6wC5aCgUmmg8sVILr9GwyZF5urX+Usp4ta44Em3upSv +eM+HJyMso/L+NgtC+eHXJLur5sg= +-----END CERTIFICATE-----` + + // Microsoft TLS RSA Root G2 (cross-signed by DigiCert Global Root G2) + // Valid until: 19/Jun/2029 + // downloaded from http://www.microsoft.com/pkiops/certs/Microsoft%20TLS%20RSA%20Root%20G2%20-%20xsign.crt + // This is the cross-signed version needed for validating the 3-level certificate chains + // used by newer Azure IMDS certificates (e.g., Microsoft TLS G2 RSA CA OCSP XX) + MicrosoftTLSRSARootG2CrossSignedPem = `-----BEGIN CERTIFICATE----- +MIIFiTCCBHGgAwIBAgIQCwxrLEZpF7BHc8ZH1K/AyDANBgkqhkiG9w0BAQwFADBh +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH +MjAeFw0yNTA1MjEwMDAwMDBaFw0yOTA2MTkyMzU5NTlaMFExCzAJBgNVBAYTAlVT +MR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xIjAgBgNVBAMTGU1pY3Jv +c29mdCBUTFMgUlNBIFJvb3QgRzIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK +AoICAQDf6oufR+EoEHGvQdYZ25JX3mur5i7erTpgg7cTmKxbuTILe+ufcidrXUCr +vhgGk7IN0hLtuHT1fy/qqBeU9jMWV4reIHwh3bfarN5OZLBazUt18+8CZE3tUtqj +jwTokfjX+z8Z/U5FOV7oKcPW8mevswCUwY3h8EoYmDn6wAmEM0EFAwWr9HXhU6Uh +klxETOZgV6SQApfH1diTBDJK7YVR7dbFuqA/Noovb0w5qARpIoQ7dRT32T60qdAH +QTiBfkZIHegZ5nC4oKoY3XK/fn21bE4ZcBGEBBOB1GL9nGvxHN3/7Kfg5seNMUu/ +8mszzNGMtv6xG6NKqF8OfzF2OD8HR2wBqKylFNqCsF8fbLyJGsASKst7lx8oLjEW +ilNMdWb5fQHWwmCqZY8xnnLLzJst5UQZk1erbo7C2S5lsHIt56HDoX5JHVln1gnU +GBJtwJVFeMnxYGrk9u4GJDtzSloRwj6XYcB47u8TpzDiSjgt7lgXEyC3NirfCzK0 +wjixkd0SsEW2fMCxHWKhnd1xEhWWAZ0KCfWx3bPZ4DhCNPZptsOvFnP+1EP4Q+RY ++U+z8+zWPZQ6QDgVqwyG0GTOGmPohJRVCVq2BLbRPpoVx2QRgNAbgg5N/0WesmUH +JR/bmsjG7NZbhVAEnxzLXSCCZ5554t/o8uhvxCByMIblnXUnNQIDAQABo4IBSzCC +AUcwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU3pGGSLehMVkx8UtfB6nciHna +qHYwHwYDVR0jBBgwFoAUTiJUIBiV5uNu5g/6+rkS7QYXjzkwDgYDVR0PAQH/BAQD +AgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMBMHYGCCsGAQUFBwEBBGowaDAkBggrBgEF +BQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEAGCCsGAQUFBzAChjRodHRw +Oi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRHbG9iYWxSb290RzIuY3J0 +MEIGA1UdHwQ7MDkwN6A1oDOGMWh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdp +Q2VydEdsb2JhbFJvb3RHMi5jcmwwEwYDVR0gBAwwCjAIBgZngQwBAgIwDQYJKoZI +hvcNAQEMBQADggEBAAu8tCs3dMVLpzYCNsav4RPMipqXG/zjRIzuVADl5EEaRvAL +djT/mVViNaqtipwMWmLMQ8DL6kodvWsdr7EZJWac93luWyWAJIGFx3ktNV9CCXjt +n+Jl1cQgUIIQj2o67RiOSImrpgn44YD8BnUWJyVaj7g6cGwYR/Bj9FMO2RU1IPOR +PRMBoOL6JAhFVnfRZ6kxQtBX/xomvsVD2FepY/+v8zrY9ntLEKKXoc9mvmdnCfm1 +TOerGSu/Ij193sb372M4LN1WxPkJUtrf44hv1W1r9whBL44+hjGf8XxK9dZhpEZG +KO9XurBvktjSdyXte6YpzjtyeRHU4KdUbTUrpHo= -----END CERTIFICATE-----` ) -var roots = []string{DigiCertGlobalRootG2Pem, DigiCertGlobalRootCAPem} +var roots = []string{ + DigiCertGlobalRootG2Pem, + DigiCertGlobalRootCAPem, + MicrosoftTLSRSARootG2SelfSignedPem, + MicrosoftTLSRSARootG2CrossSignedPem, +} diff --git a/pkg/server/plugin/nodeattestor/httpchallenge/httpchallenge.go b/pkg/server/plugin/nodeattestor/httpchallenge/httpchallenge.go index 01e4489781..65f9f4b6cf 100644 --- a/pkg/server/plugin/nodeattestor/httpchallenge/httpchallenge.go +++ b/pkg/server/plugin/nodeattestor/httpchallenge/httpchallenge.go @@ -27,7 +27,7 @@ const ( ) var ( - agentNamePattern = regexp.MustCompile("^[a-zA-z]+[a-zA-Z0-9-]$") + agentNamePattern = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9-]*$") ) func BuiltIn() catalog.BuiltIn { @@ -279,8 +279,7 @@ func buildSelectorValues(hostName string) []string { } func validateAgentName(agentName string) error { - l := agentNamePattern.FindAllStringSubmatch(agentName, -1) - if len(l) != 1 || len(l[0]) == 0 || len(agentName) > 32 { + if !agentNamePattern.MatchString(agentName) || len(agentName) > 32 { return status.Error(codes.InvalidArgument, "agent name is not valid") } return nil diff --git a/pkg/server/plugin/nodeattestor/httpchallenge/httpchallenge_test.go b/pkg/server/plugin/nodeattestor/httpchallenge/httpchallenge_test.go index baf2bf1ff9..84e8b71b29 100644 --- a/pkg/server/plugin/nodeattestor/httpchallenge/httpchallenge_test.go +++ b/pkg/server/plugin/nodeattestor/httpchallenge/httpchallenge_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" neturl "net/url" + "strings" "testing" "github.com/spiffe/go-spiffe/v2/spiffeid" @@ -250,6 +251,42 @@ func TestAttestFailures(t *testing.T) { Port: 80, }), }, + { + name: "Attest fails if agentname has a caret", + expErr: "rpc error: code = InvalidArgument desc = nodeattestor(http_challenge): agent name is not valid", + hclConf: "", + tofu: true, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "foo", + AgentName: "a^b", + Port: 80, + }), + }, + { + name: "Attest fails if agentname has an underscore", + expErr: "rpc error: code = InvalidArgument desc = nodeattestor(http_challenge): agent name is not valid", + hclConf: "", + tofu: true, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "foo", + AgentName: "a_b", + Port: 80, + }), + }, + { + name: "Attest fails if agentname has a backtick", + expErr: "rpc error: code = InvalidArgument desc = nodeattestor(http_challenge): agent name is not valid", + hclConf: "", + tofu: true, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "foo", + AgentName: "a`b", + Port: 80, + }), + }, { name: "Attest fails if required port is different from given one", expErr: "rpc error: code = InvalidArgument desc = nodeattestor(http_challenge): port 81 is not allowed to be used by this server", @@ -357,8 +394,8 @@ func TestAttestFailures(t *testing.T) { func TestAttestSucceeds(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/.well-known/spiffe/nodeattestor/http_challenge/default/challenge" { - t.Errorf("Expected to request '/.well-known/spiffe/nodeattestor/http_challenge/default/challenge', got: %s", r.URL.Path) + if !strings.HasPrefix(r.URL.Path, "/.well-known/spiffe/nodeattestor/http_challenge/") || !strings.HasSuffix(r.URL.Path, "/challenge") { + t.Errorf("Unexpected challenge path: %s", r.URL.Path) } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`123456789abcdefghijklmnopqrstuvwxyz`)) @@ -416,6 +453,42 @@ func TestAttestSucceeds(t *testing.T) { }, }, }, + { + name: "Attest succeeds for an agent name with a dash", + hclConf: "", + tofu: true, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "foo", + AgentName: "agent-01", + Port: 80, + }), + expectedAgentID: "spiffe://example.org/spire/agent/http_challenge/foo", + expectedSelectors: []*common.Selector{ + { + Type: "http_challenge", + Value: "hostname:foo", + }, + }, + }, + { + name: "Attest succeeds for an agent name with trailing digits", + hclConf: "", + tofu: true, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "foo", + AgentName: "host01", + Port: 80, + }), + expectedAgentID: "spiffe://example.org/spire/agent/http_challenge/foo", + expectedSelectors: []*common.Selector{ + { + Type: "http_challenge", + Value: "hostname:foo", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/server/plugin/nodeattestor/v1.go b/pkg/server/plugin/nodeattestor/v1.go index 4d80203b85..366dbeb784 100644 --- a/pkg/server/plugin/nodeattestor/v1.go +++ b/pkg/server/plugin/nodeattestor/v1.go @@ -11,6 +11,7 @@ import ( "github.com/spiffe/spire/proto/spire/common" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" "google.golang.org/grpc/status" ) @@ -20,6 +21,11 @@ const ( // It must not be used for security decisions (such as authentication, authorization, or trust domain selection) in attestor plugins without threat assessment. // Valid uses include diagnostics, logging, or configuration side-loading XForwardedHostKey = "X-Untrusted-Forwarded-Host" + + // XForwardedClientIPKey is the metadata key for the client IP address observed by the server. + // Note: This reflects the immediate connecting peer, and may not represent the true client origin + // in scenarios including load balancers and other middlebox patterns + XForwardedClientIPKey = "X-Forwarded-Client-IP" ) type V1 struct { @@ -38,13 +44,18 @@ func (v1 *V1) Attest(ctx context.Context, payload []byte, challengeFn func(ctx c ctx, cancel := context.WithCancel(ctx) defer cancel() - // forward original request host to downstream plugins + // Forward original request host to downstream plugins originalHost, err := getOriginalHost(ctx) if err != nil { v1.Log.WithError(err).Warn("Failed to extract ':authority' header from gRPC metadata") } ctx = metadata.AppendToOutgoingContext(ctx, XForwardedHostKey, originalHost) + // Forward observed client IP to downstream plugins + // Note: Empty string is provided if unavailable. Plugins that require it + // (e.g. x509pop with verify_client_ip enabled) handle the IP absence + ctx = metadata.AppendToOutgoingContext(ctx, XForwardedClientIPKey, getClientIP(ctx)) + stream, err := v1.NodeAttestorPluginClient.Attest(ctx) if err != nil { return nil, v1.WrapErr(err) @@ -119,6 +130,23 @@ func (v1 *V1) streamError(err error) error { return v1.WrapErr(err) } +// getClientIP returns the IP address of the connecting peer, or an empty string if unavailable +func getClientIP(ctx context.Context) string { + p, ok := peer.FromContext(ctx) + if !ok || p.Addr == nil { + return "" + } + addr := p.Addr.String() + if host, _, err := net.SplitHostPort(addr); err == nil { + addr = host + } + ip := net.ParseIP(addr) + if ip == nil { + return "" + } + return ip.String() +} + func getOriginalHost(ctx context.Context) (string, error) { authority := metadata.ValueFromIncomingContext(ctx, ":authority") if len(authority) == 0 { diff --git a/pkg/server/plugin/nodeattestor/v1_test.go b/pkg/server/plugin/nodeattestor/v1_test.go index 293c6e5f80..17d047d575 100644 --- a/pkg/server/plugin/nodeattestor/v1_test.go +++ b/pkg/server/plugin/nodeattestor/v1_test.go @@ -3,6 +3,7 @@ package nodeattestor_test import ( "context" "errors" + "net" "testing" nodeattestorv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/nodeattestor/v1" @@ -15,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" "google.golang.org/grpc/status" ) @@ -180,6 +182,79 @@ func (plugin *ForwardedHostV1Plugin) Attest(stream nodeattestorv1.NodeAttestor_A }) } +type ForwardedClientIPV1Plugin struct { + nodeattestorv1.UnimplementedNodeAttestorServer + ExpectedIP string +} + +func (plugin *ForwardedClientIPV1Plugin) Attest(stream nodeattestorv1.NodeAttestor_AttestServer) error { + ips := metadata.ValueFromIncomingContext(stream.Context(), nodeattestor.XForwardedClientIPKey) + if len(ips) == 0 { + return errors.New("client IP metadata key not present") + } + if ips[0] != plugin.ExpectedIP { + return errors.New("forwarded client IP does not match expected value") + } + if _, err := stream.Recv(); err != nil { + return err + } + return stream.Send(&nodeattestorv1.AttestResponse{ + Response: &nodeattestorv1.AttestResponse_AgentAttributes{ + AgentAttributes: &nodeattestorv1.AgentAttributes{ + SpiffeId: "spiffe://example.org/spire/agent/test/foo", + }, + }, + }) +} + +func TestGetClientIP(t *testing.T) { + attestFn := func(ctx context.Context, challenge []byte) ([]byte, error) { + return challenge, nil + } + + for _, tt := range []struct { + name string + ctx context.Context + expectedIP string + }{ + { + name: "IPv4 peer", + ctx: peer.NewContext(context.Background(), &peer.Peer{Addr: &net.TCPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345}}), + expectedIP: "192.0.2.1", + }, + { + name: "IPv6 peer", + ctx: peer.NewContext(context.Background(), &peer.Peer{Addr: &net.TCPAddr{IP: net.ParseIP("2001:db8::1"), Port: 12345}}), + expectedIP: "2001:db8::1", + }, + { + name: "no peer in context", + ctx: context.Background(), + expectedIP: "", + }, + { + name: "nil peer addr", + ctx: peer.NewContext(context.Background(), &peer.Peer{Addr: nil}), + expectedIP: "", + }, + { + name: "unix socket addr", + ctx: peer.NewContext(context.Background(), &peer.Peer{Addr: &net.UnixAddr{Name: "/tmp/spire.sock", Net: "unix"}}), + expectedIP: "", + }, + } { + t.Run(tt.name, func(t *testing.T) { + server := nodeattestorv1.NodeAttestorPluginServer(&ForwardedClientIPV1Plugin{ExpectedIP: tt.expectedIP}) + na := new(nodeattestor.V1) + plugintest.Load(t, catalog.MakeBuiltIn("test", server), na) + + result, err := na.Attest(tt.ctx, []byte("unused"), attestFn) + require.NoError(t, err) + require.NotNil(t, result) + }) + } +} + func TestHostForwarding(t *testing.T) { server := nodeattestorv1.NodeAttestorPluginServer(&ForwardedHostV1Plugin{ExpectedHost: "spire-server-xyz.spiffe.io"}) nodeattestor := new(nodeattestor.V1) diff --git a/pkg/server/plugin/nodeattestor/x509pop/x509pop.go b/pkg/server/plugin/nodeattestor/x509pop/x509pop.go index f7c9ec3fac..8c224a61f2 100644 --- a/pkg/server/plugin/nodeattestor/x509pop/x509pop.go +++ b/pkg/server/plugin/nodeattestor/x509pop/x509pop.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/url" "strings" "sync" @@ -23,7 +24,9 @@ import ( "github.com/spiffe/spire/pkg/common/plugin/x509pop" "github.com/spiffe/spire/pkg/common/pluginconf" "github.com/spiffe/spire/pkg/common/util" + "github.com/spiffe/spire/pkg/server/plugin/nodeattestor" "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) @@ -54,6 +57,7 @@ type Config struct { AgentPathTemplate string `hcl:"agent_path_template"` MaxIntermediates *int `hcl:"max_intermediates"` MaxRSAKeySize *int `hcl:"max_rsa_key_size"` + VerifyClientIP bool `hcl:"verify_client_ip"` } type configuration struct { @@ -64,6 +68,7 @@ type configuration struct { pathTemplate *agentpathtemplate.Template maxIntermediates int maxRSAKeySize int + verifyClientIP bool } func buildConfig(coreConfig catalog.CoreConfig, hclText string, status *pluginconf.Status) *configuration { @@ -151,6 +156,7 @@ func buildConfig(coreConfig catalog.CoreConfig, hclText string, status *pluginco svidPrefix: svidPrefix, maxIntermediates: maxIntermediates, maxRSAKeySize: maxRSAKeySize, + verifyClientIP: hclConfig.VerifyClientIP, } return newConfig @@ -253,6 +259,30 @@ func (p *Plugin) Attest(stream nodeattestorv1.NodeAttestor_AttestServer) error { return status.Errorf(codes.PermissionDenied, "certificate verification failed: %v", err) } + if config.verifyClientIP { + ips := metadata.ValueFromIncomingContext(stream.Context(), nodeattestor.XForwardedClientIPKey) + if len(ips) == 0 || ips[0] == "" { + return status.Error(codes.Internal, "client IP not available for verification") + } + if len(ips) > 1 { + p.log.Warn("Multiple client IP values found in metadata, using first value") + } + clientIP := net.ParseIP(ips[0]) + if clientIP == nil { + return status.Errorf(codes.Internal, "invalid client IP %q", ips[0]) + } + matched := false + for _, certIP := range leaf.IPAddresses { + if certIP.Equal(clientIP) { + matched = true + break + } + } + if !matched { + return status.Errorf(codes.PermissionDenied, "client IP %s does not match any certificate IP SAN", clientIP) + } + } + // now that the leaf certificate is trusted, issue a challenge to the node // to prove possession of the private key. challenge, err := x509pop.GenerateChallenge(leaf) diff --git a/pkg/server/plugin/nodeattestor/x509pop/x509pop_test.go b/pkg/server/plugin/nodeattestor/x509pop/x509pop_test.go index 7f40c87fe5..e667d855d7 100644 --- a/pkg/server/plugin/nodeattestor/x509pop/x509pop_test.go +++ b/pkg/server/plugin/nodeattestor/x509pop/x509pop_test.go @@ -13,7 +13,9 @@ import ( "errors" "fmt" "math/big" + "net" "os" + "path/filepath" "testing" "time" @@ -32,6 +34,7 @@ import ( "github.com/spiffe/spire/test/util" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" + "google.golang.org/grpc/peer" ) func TestX509PoP(t *testing.T) { @@ -553,6 +556,128 @@ func expectNoChallenge(context.Context, []byte) ([]byte, error) { return nil, errors.New("challenge is not expected") } +func (s *Suite) TestVerifyClientIP() { + clientIP := net.ParseIP("192.0.2.1") + + makePayload := func(t *testing.T, certs [][]byte) []byte { + return marshal(t, &x509pop.AttestationData{Certificates: certs}) + } + + s.T().Run("client IP not available", func(t *testing.T) { + config := s.createConfiguration("ca_bundle_path", "verify_client_ip = true") + attestor := s.loadPlugin(t, config) + payload := makePayload(t, s.leafBundle) + challengeFn := func(ctx context.Context, challenge []byte) ([]byte, error) { + popChallenge := new(x509pop.Challenge) + unmarshal(t, challenge, popChallenge) + response, err := x509pop.CalculateResponse(s.leafKey, popChallenge) + require.NoError(t, err) + return marshal(t, response), nil + } + result, err := attestor.Attest(context.Background(), payload, challengeFn) + spiretest.RequireGRPCStatusContains(t, err, codes.Internal, "client IP not available for verification") + require.Nil(t, result) + }) + + s.T().Run("client IP does not match cert SAN", func(t *testing.T) { + config := s.createConfiguration("ca_bundle_path", "verify_client_ip = true") + attestor := s.loadPlugin(t, config) + payload := makePayload(t, s.leafBundle) + ctx := peer.NewContext(context.Background(), &peer.Peer{ + Addr: &net.TCPAddr{IP: clientIP, Port: 12345}, + }) + challengeFn := func(ctx context.Context, challenge []byte) ([]byte, error) { + popChallenge := new(x509pop.Challenge) + unmarshal(t, challenge, popChallenge) + response, err := x509pop.CalculateResponse(s.leafKey, popChallenge) + require.NoError(t, err) + return marshal(t, response), nil + } + result, err := attestor.Attest(ctx, payload, challengeFn) + spiretest.RequireGRPCStatusContains(t, err, codes.PermissionDenied, "does not match any certificate IP SAN") + require.Nil(t, result) + }) + + s.T().Run("client IP matches cert SAN", func(t *testing.T) { + certChain, leafKey, rootCertPath := generateCertChainWithIPSAN(t, clientIP) + config := fmt.Sprintf("ca_bundle_path = %q\nverify_client_ip = true\n", rootCertPath) + attestor := s.loadPlugin(t, config) + payload := makePayload(t, certChain) + ctx := peer.NewContext(context.Background(), &peer.Peer{ + Addr: &net.TCPAddr{IP: clientIP, Port: 12345}, + }) + challengeFn := func(ctx context.Context, challenge []byte) ([]byte, error) { + popChallenge := new(x509pop.Challenge) + unmarshal(t, challenge, popChallenge) + response, err := x509pop.CalculateResponse(leafKey, popChallenge) + require.NoError(t, err) + return marshal(t, response), nil + } + result, err := attestor.Attest(ctx, payload, challengeFn) + require.NoError(t, err) + require.NotNil(t, result) + }) + + s.T().Run("client IPv6 matches cert SAN", func(t *testing.T) { + ipv6 := net.ParseIP("2001:db8::1") + certChain, leafKey, rootCertPath := generateCertChainWithIPSAN(t, ipv6) + config := fmt.Sprintf("ca_bundle_path = %q\nverify_client_ip = true\n", rootCertPath) + attestor := s.loadPlugin(t, config) + payload := makePayload(t, certChain) + ctx := peer.NewContext(context.Background(), &peer.Peer{ + Addr: &net.TCPAddr{IP: ipv6, Port: 12345}, + }) + challengeFn := func(ctx context.Context, challenge []byte) ([]byte, error) { + popChallenge := new(x509pop.Challenge) + unmarshal(t, challenge, popChallenge) + response, err := x509pop.CalculateResponse(leafKey, popChallenge) + require.NoError(t, err) + return marshal(t, response), nil + } + result, err := attestor.Attest(ctx, payload, challengeFn) + require.NoError(t, err) + require.NotNil(t, result) + }) +} + +func generateCertChainWithIPSAN(t *testing.T, ip net.IP) (certChain [][]byte, leafKey crypto.PrivateKey, rootCertPath string) { + t.Helper() + + rootKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + rootTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + BasicConstraintsValid: true, + IsCA: true, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + } + rootDER, err := x509.CreateCertificate(rand.Reader, rootTemplate, rootTemplate, &rootKey.PublicKey, rootKey) + require.NoError(t, err) + rootCert, err := x509.ParseCertificate(rootDER) + require.NoError(t, err) + + leafKeyRSA, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + leafTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + KeyUsage: x509.KeyUsageDigitalSignature, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + IPAddresses: []net.IP{ip}, + } + leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, rootCert, &leafKeyRSA.PublicKey, rootKey) + require.NoError(t, err) + + rootPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootDER}) + rootCertPath = filepath.Join(t.TempDir(), "root.pem") + require.NoError(t, os.WriteFile(rootCertPath, rootPEM, 0o600)) + + return [][]byte{leafDER}, leafKeyRSA, rootCertPath +} + func generateCertWithRSAKeySize(t *testing.T, keySize int) []byte { // Generate RSA key with specified size key, err := rsa.GenerateKey(rand.Reader, keySize) diff --git a/pkg/server/plugin/upstreamauthority/spire/spire.go b/pkg/server/plugin/upstreamauthority/spire/spire.go index 565920ecbc..c9debf84b9 100644 --- a/pkg/server/plugin/upstreamauthority/spire/spire.go +++ b/pkg/server/plugin/upstreamauthority/spire/spire.go @@ -46,7 +46,13 @@ func buildConfig(coreConfig catalog.CoreConfig, hclText string, status *pluginco return nil } - // TODO: add field validation + if newConfig.ServerAddr == "" { + status.ReportError("server_address is required") + } + if newConfig.ServerPort == "" { + status.ReportError("server_port is required") + } + validateWorkloadAPIConfig(newConfig, status) return newConfig } diff --git a/pkg/server/plugin/upstreamauthority/spire/spire_posix.go b/pkg/server/plugin/upstreamauthority/spire/spire_posix.go index 2cfcd3fb37..fe2657cc1f 100644 --- a/pkg/server/plugin/upstreamauthority/spire/spire_posix.go +++ b/pkg/server/plugin/upstreamauthority/spire/spire_posix.go @@ -6,12 +6,22 @@ import ( "errors" "net" + "github.com/spiffe/spire/pkg/common/pluginconf" "github.com/spiffe/spire/pkg/common/util" ) +func validateWorkloadAPIConfig(config *Configuration, status *pluginconf.Status) { + if config.Experimental.WorkloadAPINamedPipeName != "" { + status.ReportError("configuration: workload_api_named_pipe_name is not supported on this platform; please use workload_api_socket instead") + } + if config.WorkloadAPISocket == "" { + status.ReportError("workload_api_socket is required") + } +} + func (p *Plugin) getWorkloadAPIAddr() (net.Addr, error) { if p.config.Experimental.WorkloadAPINamedPipeName != "" { - return nil, errors.New("configuration: workload_api_named_pipe_name is not supported in this platform; please use workload_api_socket instead") + return nil, errors.New("configuration: workload_api_named_pipe_name is not supported on this platform; please use workload_api_socket instead") } return util.GetUnixAddrWithAbsPath(p.config.WorkloadAPISocket) } diff --git a/pkg/server/plugin/upstreamauthority/spire/spire_posix_test.go b/pkg/server/plugin/upstreamauthority/spire/spire_posix_test.go index eaa8654906..acc8e2b055 100644 --- a/pkg/server/plugin/upstreamauthority/spire/spire_posix_test.go +++ b/pkg/server/plugin/upstreamauthority/spire/spire_posix_test.go @@ -26,13 +26,20 @@ func configureCasesOS(t *testing.T) []configureCase { expectWorkloadAPIAddr: addr, expectServerAddr: "localhost:8081", }, + { + name: "missing workload api socket", + serverAddr: "localhost", + serverPort: "8081", + expectCode: codes.InvalidArgument, + expectMsgPrefix: "workload_api_socket is required", + }, { name: "workload_api_named_pipe_name configured", serverAddr: "localhost", serverPort: "8081", workloadAPINamedPipeName: "socketPath", expectCode: codes.InvalidArgument, - expectMsgPrefix: "unable to set Workload API address: configuration: workload_api_named_pipe_name is not supported in this platform; please use workload_api_socket instead", + expectMsgPrefix: "configuration: workload_api_named_pipe_name is not supported on this platform; please use workload_api_socket instead", }, } } diff --git a/pkg/server/plugin/upstreamauthority/spire/spire_test.go b/pkg/server/plugin/upstreamauthority/spire/spire_test.go index 4565403c44..2bdec6499d 100644 --- a/pkg/server/plugin/upstreamauthority/spire/spire_test.go +++ b/pkg/server/plugin/upstreamauthority/spire/spire_test.go @@ -77,6 +77,20 @@ func TestConfigure(t *testing.T) { expectCode: codes.InvalidArgument, expectMsgPrefix: "server core configuration must contain trust_domain", }, + { + name: "missing server address", + serverPort: "8081", + workloadAPISocket: "socketPath", + expectCode: codes.InvalidArgument, + expectMsgPrefix: "server_address is required", + }, + { + name: "missing server port", + serverAddr: "localhost", + workloadAPISocket: "socketPath", + expectCode: codes.InvalidArgument, + expectMsgPrefix: "server_port is required", + }, } cases = append(cases, configureCasesOS(t)...) for _, tt := range cases { @@ -177,15 +191,6 @@ func TestMintX509CA(t *testing.T) { return csr, pubKey }, }, - { - name: "invalid server address", - getCSR: func() ([]byte, crypto.PublicKey) { - return csr, pubKey - }, - customServerAddr: "localhost", - expectCode: codes.Internal, - expectMsgPrefix: `upstreamauthority(spire): unable to request a new Downstream X509CA: rpc error: code = Unavailable desc = delegating_resolver: invalid target address ":": missing port after port-separator colon`, - }, { name: "invalid scheme", getCSR: func() ([]byte, crypto.PublicKey) { diff --git a/pkg/server/plugin/upstreamauthority/spire/spire_windows.go b/pkg/server/plugin/upstreamauthority/spire/spire_windows.go index 85b43d6cb0..fb8b0c478a 100644 --- a/pkg/server/plugin/upstreamauthority/spire/spire_windows.go +++ b/pkg/server/plugin/upstreamauthority/spire/spire_windows.go @@ -7,11 +7,21 @@ import ( "net" "github.com/spiffe/spire/pkg/common/namedpipe" + "github.com/spiffe/spire/pkg/common/pluginconf" ) +func validateWorkloadAPIConfig(config *Configuration, status *pluginconf.Status) { + if config.WorkloadAPISocket != "" { + status.ReportError("configuration: workload_api_socket is not supported on this platform; please use workload_api_named_pipe_name instead") + } + if config.Experimental.WorkloadAPINamedPipeName == "" { + status.ReportError("workload_api_named_pipe_name is required") + } +} + func (p *Plugin) getWorkloadAPIAddr() (net.Addr, error) { if p.config.WorkloadAPISocket != "" { - return nil, errors.New("configuration: workload_api_socket is not supported in this platform; please use workload_api_named_pipe_name instead") + return nil, errors.New("configuration: workload_api_socket is not supported on this platform; please use workload_api_named_pipe_name instead") } return namedpipe.AddrFromName(p.config.Experimental.WorkloadAPINamedPipeName), nil } diff --git a/pkg/server/plugin/upstreamauthority/spire/spire_windows_test.go b/pkg/server/plugin/upstreamauthority/spire/spire_windows_test.go index 05467e8d9e..79f05ca279 100644 --- a/pkg/server/plugin/upstreamauthority/spire/spire_windows_test.go +++ b/pkg/server/plugin/upstreamauthority/spire/spire_windows_test.go @@ -25,12 +25,19 @@ func configureCasesOS(*testing.T) []configureCase { expectServerAddr: "localhost:8081", }, { - name: "workload_api_named_pipe_name configured", + name: "missing workload api named pipe", + serverAddr: "localhost", + serverPort: "8081", + expectCode: codes.InvalidArgument, + expectMsgPrefix: "workload_api_named_pipe_name is required", + }, + { + name: "workload_api_socket configured", serverAddr: "localhost", serverPort: "8081", workloadAPISocket: "socketPath", expectCode: codes.InvalidArgument, - expectMsgPrefix: "unable to set Workload API address: configuration: workload_api_socket is not supported in this platform; please use workload_api_named_pipe_name instead", + expectMsgPrefix: "configuration: workload_api_socket is not supported on this platform; please use workload_api_named_pipe_name instead", }, } } diff --git a/pkg/server/server.go b/pkg/server/server.go index 164956f1b5..40160c3ce0 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -2,6 +2,7 @@ package server import ( "context" + "crypto/x509" "errors" "fmt" "net/http" @@ -13,6 +14,8 @@ import ( "github.com/andres-erbsen/clock" "github.com/sirupsen/logrus" + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/go-spiffe/v2/svid/x509svid" bundlev1 "github.com/spiffe/spire-api-sdk/proto/spire/api/server/bundle/v1" server_util "github.com/spiffe/spire/cmd/spire-server/util" "github.com/spiffe/spire/pkg/common/diskutil" @@ -35,6 +38,7 @@ import ( "github.com/spiffe/spire/pkg/server/credvalidator" "github.com/spiffe/spire/pkg/server/datastore" "github.com/spiffe/spire/pkg/server/endpoints" + "github.com/spiffe/spire/pkg/server/endpoints/bundle" "github.com/spiffe/spire/pkg/server/hostservice/agentstore" "github.com/spiffe/spire/pkg/server/hostservice/identityprovider" "github.com/spiffe/spire/pkg/server/node" @@ -102,11 +106,50 @@ func (s *Server) run(ctx context.Context) (err error) { defer stopProfiling() } + var svidRotator *svid.Rotator + var bundleCache *bundle.Cache metrics, err := telemetry.NewMetrics(&telemetry.MetricsConfig{ FileConfig: s.config.Telemetry, Logger: s.config.Log.WithField(telemetry.SubsystemName, telemetry.Telemetry), ServiceName: telemetry.SpireServer, TrustDomain: s.config.TrustDomain.Name(), + TLSPolicy: s.config.TLSPolicy, + GetX509SVID: func() (*x509svid.SVID, error) { + if svidRotator == nil { + return nil, errors.New("server SVID rotator is not initialized") + } + + state := svidRotator.State() + if len(state.SVID) == 0 { + return nil, errors.New("no certificates found") + } + + id, err := x509svid.IDFromCert(state.SVID[0]) + if err != nil { + return nil, err + } + + return &x509svid.SVID{ + ID: id, + Certificates: state.SVID, + PrivateKey: state.Key, + }, nil + }, + GetX509BundleAuthorities: func(td spiffeid.TrustDomain) ([]*x509.Certificate, error) { + if bundleCache == nil { + return nil, errors.New("server bundle cache is not initialized") + } + + serverBundle, err := bundleCache.FetchBundleX509(ctx, td) + if err != nil { + return nil, fmt.Errorf("get bundle from datastore: %w", err) + } + if serverBundle == nil { + return nil, fmt.Errorf("no bundle found for trust domain %q", td) + } + + return serverBundle.X509Authorities(), nil + }, }) if err != nil { return err @@ -135,6 +178,7 @@ func (s *Server) run(ctx context.Context) (err error) { return err } defer cat.Close() + bundleCache = bundle.NewCache(cat.DataStore, clock.New()) bundlePublishingManager, err := s.newBundlePublishingManager(cat.BundlePublishers, cat.DataStore) if err != nil { @@ -172,7 +216,7 @@ func (s *Server) run(ctx context.Context) (err error) { return err } - svidRotator, err := s.newSVIDRotator(ctx, serverCA, metrics) + svidRotator, err = s.newSVIDRotator(ctx, serverCA, metrics) if err != nil { return err } @@ -403,6 +447,7 @@ func (s *Server) newNodeManager(cat catalog.Catalog, metrics telemetry.Metrics) DataStore: cat.GetDataStore(), Log: s.config.Log.WithField(telemetry.SubsystemName, telemetry.NodeManager), Metrics: metrics, + BatchSize: s.config.PruneAttestedNodesBatchSize, PruneArgs: node.PruneArgs{ ExpiredFor: s.config.PruneAttestedNodesExpiredFor, IncludeNonReattestable: s.config.PruneNonReattestableNodes, diff --git a/pkg/server/svid/rotator.go b/pkg/server/svid/rotator.go index a535ef137c..5c09d66ece 100644 --- a/pkg/server/svid/rotator.go +++ b/pkg/server/svid/rotator.go @@ -6,8 +6,8 @@ import ( "crypto/x509" "time" - "github.com/imkira/go-observer" "github.com/sirupsen/logrus" + "github.com/spiffe/spire/pkg/common/observer" "github.com/spiffe/spire/pkg/common/telemetry" telemetry_server "github.com/spiffe/spire/pkg/common/telemetry/server" "github.com/spiffe/spire/pkg/server/ca" diff --git a/pkg/server/svid/rotator_config.go b/pkg/server/svid/rotator_config.go index 56577eb506..10cb955327 100644 --- a/pkg/server/svid/rotator_config.go +++ b/pkg/server/svid/rotator_config.go @@ -4,8 +4,8 @@ import ( "time" "github.com/andres-erbsen/clock" - "github.com/imkira/go-observer" "github.com/sirupsen/logrus" + "github.com/spiffe/spire/pkg/common/observer" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/server/ca" "github.com/spiffe/spire/pkg/server/plugin/keymanager" diff --git a/pkg/server/svid/rotator_test.go b/pkg/server/svid/rotator_test.go index 74cca87e8f..f702bb4adf 100644 --- a/pkg/server/svid/rotator_test.go +++ b/pkg/server/svid/rotator_test.go @@ -9,10 +9,10 @@ import ( "testing" "time" - observer "github.com/imkira/go-observer" "github.com/sirupsen/logrus" "github.com/sirupsen/logrus/hooks/test" "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire/pkg/common/observer" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/common/x509util" "github.com/spiffe/spire/pkg/server/ca" diff --git a/test/clock/clock.go b/test/clock/clock.go index 6c89d7162c..df30139c2a 100644 --- a/test/clock/clock.go +++ b/test/clock/clock.go @@ -62,6 +62,17 @@ func (m *Mock) WaitForAfterCh() <-chan time.Duration { return m.afterC } +// SleepCh returns the channel that Sleep notifies when a sleep begins, +// carrying the sleep duration. The notification is a non-blocking send on a +// buffered channel, so it can be dropped if a previous one has not been +// drained. Drive a Sleep-based retry loop by receiving each notification and +// advancing the clock by its duration before the next Sleep can begin: that +// keeps at most one sleep outstanding (so nothing is dropped) and avoids +// coupling to the number of sleeps. +func (m *Mock) SleepCh() <-chan time.Duration { + return m.sleepC +} + // WaitForTimer waits up to the specified timeout for Timer to be called on the clock. func (m *Mock) WaitForTimer(timeout time.Duration, format string, args ...any) { select { diff --git a/test/fakes/fakedatastore/fakedatastore.go b/test/fakes/fakedatastore/fakedatastore.go index 33a56a276d..4d665222a2 100644 --- a/test/fakes/fakedatastore/fakedatastore.go +++ b/test/fakes/fakedatastore/fakedatastore.go @@ -148,6 +148,13 @@ func (s *DataStore) FetchAttestedNode(ctx context.Context, spiffeID string) (*co return s.ds.FetchAttestedNode(ctx, spiffeID) } +func (s *DataStore) FetchAttestedNodes(ctx context.Context, spiffeIDs []string) (map[string]*common.AttestedNode, error) { + if err := s.getNextError(); err != nil { + return nil, err + } + return s.ds.FetchAttestedNodes(ctx, spiffeIDs) +} + func (s *DataStore) ListAttestedNodes(ctx context.Context, req *datastore.ListAttestedNodesRequest) (*datastore.ListAttestedNodesResponse, error) { if err := s.getNextError(); err != nil { return nil, err @@ -169,11 +176,11 @@ func (s *DataStore) DeleteAttestedNode(ctx context.Context, spiffeID string) (*c return s.ds.DeleteAttestedNode(ctx, spiffeID) } -func (s *DataStore) PruneAttestedExpiredNodes(ctx context.Context, expiredBefore time.Time, includeNonReattestable bool) error { +func (s *DataStore) PruneAttestedExpiredNodes(ctx context.Context, expiredBefore time.Time, includeNonReattestable bool, batchSize int) error { if err := s.getNextError(); err != nil { return err } - return s.ds.PruneAttestedExpiredNodes(ctx, expiredBefore, includeNonReattestable) + return s.ds.PruneAttestedExpiredNodes(ctx, expiredBefore, includeNonReattestable, batchSize) } func (s *DataStore) ListAttestedNodeEvents(ctx context.Context, req *datastore.ListAttestedNodeEventsRequest) (*datastore.ListAttestedNodeEventsResponse, error) { diff --git a/test/fixture/config/agent_bad_nested_ratelimit_block.conf b/test/fixture/config/agent_bad_nested_ratelimit_block.conf new file mode 100644 index 0000000000..06d1aba05f --- /dev/null +++ b/test/fixture/config/agent_bad_nested_ratelimit_block.conf @@ -0,0 +1,8 @@ +agent { + experimental { + ratelimit { + unknown_option1 = "unknown_option1" + unknown_option2 = "unknown_option2" + } + } +} diff --git a/test/integration/common b/test/integration/common index 8ca9a45870..8ca1b303bf 100644 --- a/test/integration/common +++ b/test/integration/common @@ -31,13 +31,41 @@ fail-now() { exit 1 } +# retry runs the given command, retrying with exponential backoff if it fails. +# It is intended to harden steps against transient registry/network errors +# (e.g. Docker Hub image pull timeouts) that would otherwise cause spurious CI +# failures. +retry() { + local attempts=5 + local delay=5 + local max_delay=60 + local attempt=1 + while true; do + if "$@"; then + return 0 + fi + if [ "${attempt}" -ge "${attempts}" ]; then + log-warn "command failed after ${attempt} attempt(s): $*" + return 1 + fi + log-warn "command failed (attempt ${attempt}/${attempts}), retrying in ${delay}s: $*" + sleep "${delay}" + attempt=$((attempt + 1)) + delay=$((delay * 2)) + if [ "${delay}" -gt "${max_delay}" ]; then + delay="${max_delay}" + fi + done +} + docker-up() { if [ $# -eq 0 ]; then log-debug "bringing up services..." else log-debug "bringing up $*..." fi - docker compose up -d "$@" || fail-now "failed to bring up services." + # Retry to harden against transient image pull failures during startup. + retry docker compose up -d "$@" || fail-now "failed to bring up services." } docker-wait-for-healthy() { @@ -116,6 +144,60 @@ check-server-started() { fail-now "timed out waiting for server to start" } +# Counts how many times the server has logged that it started its APIs. The +# server emits the "Starting Server APIs" line twice per boot (TCP and UDS +# listeners). +count-server-api-starts() { + # Capture the logs first so a "docker compose logs" failure still surfaces + # under errexit/pipefail. Only grep's no-match exit code is tolerated, since + # zero matches is a valid count. + local logs + logs=$(docker compose logs "$1") + echo "${logs}" | grep -c "Starting Server APIs" || true +} + +# Like check-server-started but safe to use after "docker compose restart". +# A restart reuses the same container, so its log still contains the +# "Starting Server APIs" markers from the pre-restart boot. Callers capture the +# marker count before restarting and pass it here so we wait for the restarted +# server to emit its own two markers rather than returning on the stale ones. +wait-for-server-restarted() { + local container="$1" + local baseline="$2" + local want=$((baseline + 2)) + MAXCHECKS=30 + CHECKINTERVAL=1 + for ((i=1;i<=MAXCHECKS;i++)); do + log-info "checking for restarted server APIs ($i of $MAXCHECKS max)..." + if [ "$(count-server-api-starts "$container")" -ge "$want" ]; then + return 0 + fi + sleep "${CHECKINTERVAL}" + done + + fail-now "timed out waiting for server to restart" +} + +# Restarts the given server containers and waits for each to be ready again. +# Unlike a fresh start, "docker compose restart" reuses the container and keeps +# the pre-restart "Starting Server APIs" markers in its log, so the marker count +# is baselined before the restart and we wait for it to grow afterwards. +docker-spire-server-restart() { + local -a baselines=() + local container + for container in "$@"; do + baselines+=("$(count-server-api-starts "${container}")") + done + + docker compose restart "$@" + + local i=0 + for container in "$@"; do + wait-for-server-restarted "${container}" "${baselines[$i]}" + i=$((i + 1)) + done +} + check-log-line() { # Check at most 30 times (with one second in between) that the agent has # successfully synced down the workload entry. @@ -206,7 +288,8 @@ ENTRYPOINT ["/usr/bin/dumb-init", "supervisord", "--nodaemon", "--configuration" CMD [] EOF - docker build --target envoy-agent-mashup -t envoy-agent-mashup . + # Retry to harden against transient pulls of the envoy base image. + retry docker build --target envoy-agent-mashup -t envoy-agent-mashup . } curl-with-retry() { @@ -339,6 +422,11 @@ start-kind-cluster() { local kind_name=$2 local kind_config_path=$3 + # Pre-pull the node image with retry to harden against transient Docker Hub + # failures. kind uses the locally cached image when present, so it won't + # attempt its own (un-retried) pull during cluster creation. + retry docker pull "${K8SIMAGE}" || fail-now "unable to pull node image ${K8SIMAGE}" + log-info "starting cluster..." "${kind_path}" create cluster --name "${kind_name}" --config "${kind_config_path}" --image "${K8SIMAGE}" || fail-now "unable to create cluster" } diff --git a/test/integration/setup/brokerclient/build.sh b/test/integration/setup/brokerclient/build.sh new file mode 100755 index 0000000000..f1f5e97960 --- /dev/null +++ b/test/integration/setup/brokerclient/build.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +cd "${DIR}" && CGO_ENABLED=0 GOOS=linux go build -o "$1" diff --git a/test/integration/setup/brokerclient/main.go b/test/integration/setup/brokerclient/main.go new file mode 100644 index 0000000000..35eecf0543 --- /dev/null +++ b/test/integration/setup/brokerclient/main.go @@ -0,0 +1,168 @@ +// brokerclient is the e2e test driver for the SPIFFE Broker API. It fetches +// its own SVID from the Workload API, then dials the agent's broker endpoint +// with mTLS and exercises the requested scenario (PID reference, Kubernetes +// object reference). It asserts either a specific SPIFFE ID in the response +// or a specific gRPC error code. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "time" + + "github.com/spiffe/go-spiffe/v2/exp/proto/spiffe/broker" + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig" + "github.com/spiffe/go-spiffe/v2/workloadapi" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/anypb" +) + +var ( + workloadAPIAddr = flag.String("workload-api", "unix:///run/spire/agent-sockets/api.sock", "Workload API socket URI") + brokerAddr = flag.String("broker-addr", "unix:///run/spire/broker-sockets/broker.sock", "Broker API socket URI") + trustDomain = flag.String("trust-domain", "example.org", "Trust domain to authorize when dialing the broker") + refType = flag.String("ref-type", "", "Reference type: pid|object") + pid = flag.Int("pid", 0, "PID for pid reference") + plural = flag.String("plural", "", "K8s resource plural (e.g. pods, deployments, kustomizations)") + group = flag.String("group", "", "K8s resource group (e.g. core, apps, kustomize.toolkit.fluxcd.io)") + namespace = flag.String("namespace", "", "K8s object namespace") + name = flag.String("name", "", "K8s object name") + uid = flag.String("uid", "", "K8s object UID") + expectedSPIFFE = flag.String("expected-spiffe", "", "Expected SPIFFE ID in the response (omit when expecting an error)") + expectErr = flag.String("expect-err", "", "Expected gRPC code (e.g. PermissionDenied, Unavailable)") + skipBrokerDial = flag.Bool("skip-broker", false, "Only verify Workload API fetches an SVID; do not call the broker API") +) + +func main() { + flag.Parse() + if err := run(); err != nil { + log.Fatalf("brokerclient: %v", err) + } + log.Print("brokerclient: OK") +} + +func run() error { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + src, err := workloadapi.NewX509Source(ctx, + workloadapi.WithClientOptions(workloadapi.WithAddr(*workloadAPIAddr)), + ) + if err != nil { + return fmt.Errorf("workload API: %w", err) + } + defer src.Close() + + own, err := src.GetX509SVID() + if err != nil { + return fmt.Errorf("get own SVID: %w", err) + } + log.Printf("own SPIFFE ID: %s", own.ID) + + if *skipBrokerDial { + return nil + } + + req, err := buildRequest() + if err != nil { + return err + } + + td, err := spiffeid.TrustDomainFromString(*trustDomain) + if err != nil { + return fmt.Errorf("parse trust domain: %w", err) + } + tlsCfg := tlsconfig.MTLSClientConfig(src, src, tlsconfig.AuthorizeMemberOf(td)) + + conn, err := grpc.NewClient(*brokerAddr, + grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)), + ) + if err != nil { + return fmt.Errorf("dial broker: %w", err) + } + defer conn.Close() + + client := broker.NewAPIClient(conn) + ctx = metadata.AppendToOutgoingContext(ctx, "broker.spiffe.io", "true") + + stream, err := client.SubscribeToX509SVID(ctx, req) + if err != nil { + return checkErr(err) + } + resp, err := stream.Recv() + if err != nil { + return checkErr(err) + } + return checkResponse(resp) +} + +func buildRequest() (*broker.SubscribeToX509SVIDRequest, error) { + var packed *anypb.Any + var err error + switch *refType { + case "pid": + packed, err = anypb.New(&broker.WorkloadPIDReference{Pid: int32(*pid)}) + case "object": + ref := &broker.KubernetesObjectReference{ + Type: &broker.KubernetesObjectType{Plural: *plural, Group: *group}, + } + if *namespace != "" || *name != "" { + ref.Key = &broker.KubernetesObjectKey{Namespace: *namespace, Name: *name} + } + if *uid != "" { + ref.Uid = *uid + } + packed, err = anypb.New(ref) + default: + return nil, fmt.Errorf("unknown ref-type %q", *refType) + } + if err != nil { + return nil, fmt.Errorf("packing reference: %w", err) + } + return &broker.SubscribeToX509SVIDRequest{ + Reference: &broker.WorkloadReference{Reference: packed}, + }, nil +} + +func checkErr(err error) error { + code := status.Code(err).String() + if *expectErr != "" { + if code == *expectErr { + log.Printf("got expected error code %s: %v", code, err) + return nil + } + return fmt.Errorf("expected error code %s, got %s (%w)", *expectErr, code, err) + } + return fmt.Errorf("unexpected error (code %s): %w", code, err) +} + +func checkResponse(resp *broker.SubscribeToX509SVIDResponse) error { + if *expectErr != "" { + return fmt.Errorf("expected error %s, but got response with %d SVIDs", *expectErr, len(resp.Svids)) + } + log.Printf("got %d SVIDs in response", len(resp.Svids)) + if *expectedSPIFFE == "" { + if len(resp.Svids) == 0 { + return errors.New("response carried no SVIDs") + } + return nil + } + for _, svid := range resp.Svids { + if svid.SpiffeId == *expectedSPIFFE { + log.Printf("found expected SVID: %s", svid.SpiffeId) + return nil + } + } + got := []string{} + for _, svid := range resp.Svids { + got = append(got, svid.SpiffeId) + } + return fmt.Errorf("expected SVID %s, response had: %v", *expectedSPIFFE, got) +} diff --git a/test/integration/suites/agent-cli/08-logger b/test/integration/suites/agent-cli/08-logger new file mode 100755 index 0000000000..698cd14f87 --- /dev/null +++ b/test/integration/suites/agent-cli/08-logger @@ -0,0 +1,62 @@ +#!/bin/bash + +get-log-level() { + docker compose exec -T spire-agent \ + /opt/spire/bin/spire-agent logger get -output json \ + -socketPath /opt/debug.sock | jq -r .current_level +} + +# Check current log level is DEBUG +if [[ $(get-log-level) != "DEBUG" ]]; then + fail-now "Agent log level is not set to DEBUG" +fi + +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/workload-logged" \ + -selector "unix:uid:8001" + +docker compose exec -T spire-server \ + /opt/spire/bin/spire-server entry create \ + -parentID "spiffe://domain.test/spire/agent/x509pop/$(fingerprint conf/agent/agent.crt.pem)" \ + -spiffeID "spiffe://domain.test/workload-not-logged" \ + -selector "unix:uid:8002" + +check-synced-entry spire-agent "spiffe://domain.test/workload-logged" +check-synced-entry spire-agent "spiffe://domain.test/workload-not-logged" + +docker compose exec -u 8001 -T spire-agent \ + /opt/spire/bin/spire-agent api fetch jwt -audience shouldappearinlogs + +if ! docker compose logs spire-agent | grep FetchJWTSVID | grep -q workload-logged; then + fail-now "FetchJWTSVID was not logged when it was supposed to." +fi + +docker compose exec -T spire-agent \ + /opt/spire/bin/spire-agent logger set -level info -socketPath /opt/debug.sock + +if [[ $(get-log-level) != "INFO" ]]; then + fail-now "Agent log level is not set to INFO" +fi + +docker compose exec -u 8002 -T spire-agent \ + /opt/spire/bin/spire-agent api fetch jwt -audience shouldnotappearinlogs + +if docker compose logs spire-agent | grep FetchJWTSVID | grep -q workload-not-logged; then + fail-now "FetchJWTSVID was logged when it was not supposed to." +fi + +docker compose exec -T spire-agent \ + /opt/spire/bin/spire-agent logger reset -socketPath /opt/debug.sock + +if [[ $(get-log-level) != "DEBUG" ]]; then + fail-now "Agent log level is not set to DEBUG" +fi + +docker compose exec -u 8002 -T spire-agent \ + /opt/spire/bin/spire-agent api fetch jwt -audience shouldnotappearinlogs + +if ! docker compose logs spire-agent | grep FetchJWTSVID | grep -q workload-not-logged; then + fail-now "FetchJWTSVID was not logged when it was supposed to." +fi diff --git a/test/integration/suites/broker-api-k8s/00-setup b/test/integration/suites/broker-api-k8s/00-setup new file mode 100755 index 0000000000..c74edff085 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/00-setup @@ -0,0 +1,27 @@ +#!/bin/bash + +set -e + +mkdir -p ./bin + +KIND_PATH=./bin/kind +KUBECTL_PATH=./bin/kubectl + +download-kind "${KIND_PATH}" +download-kubectl "${KUBECTL_PATH}" + +start-kind-cluster "${KIND_PATH}" broker-api-k8s ./conf/kind-config.yaml + +# Build the brokerclient binary and bake it into a tiny image. +log-info "building brokerclient and broker-client image..." +"${ROOTDIR}/setup/brokerclient/build.sh" "$(pwd)/conf/brokerclient" +docker build -t broker-client:latest-local -f ./conf/broker-client.Dockerfile ./conf + +container_images=( + "spire-server:latest-local" + "spire-agent:latest-local" + "broker-client:latest-local" +) +load-images "${KIND_PATH}" broker-api-k8s "${container_images[@]}" + +set-kubectl-context "${KUBECTL_PATH}" kind-broker-api-k8s diff --git a/test/integration/suites/broker-api-k8s/01-apply-config b/test/integration/suites/broker-api-k8s/01-apply-config new file mode 100755 index 0000000000..3e86c28f56 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/01-apply-config @@ -0,0 +1,63 @@ +#!/bin/bash + +set -e + +source init-kubectl + +wait-for-rollout() { + ns=$1 + obj=$2 + MAXROLLOUTCHECKS=12 + ROLLOUTCHECKINTERVAL=15s + for ((i=0; i<${MAXROLLOUTCHECKS}; i++)); do + log-info "checking rollout status for ${ns} ${obj}..." + if ./bin/kubectl "-n${ns}" rollout status "${obj}" --timeout="${ROLLOUTCHECKINTERVAL}"; then + return + fi + log-warn "describing ${ns} ${obj}..." + ./bin/kubectl "-n${ns}" describe "${obj}" || true + log-warn "logs for ${ns} ${obj}..." + ./bin/kubectl "-n${ns}" logs --all-containers "${obj}" || true + done + fail-now "Failed waiting for ${obj} to roll out." +} + +wait-for-pod-ready() { + ns=$1 + pod=$2 + MAXCHECKS=24 + INTERVAL=5 + for ((i=0; i<${MAXCHECKS}; i++)); do + log-info "checking readiness of ${ns}/${pod}..." + if ./bin/kubectl "-n${ns}" wait --for=condition=Ready "pod/${pod}" --timeout=10s; then + return + fi + sleep "${INTERVAL}" + done + ./bin/kubectl "-n${ns}" describe "pod/${pod}" || true + fail-now "Pod ${ns}/${pod} did not become ready" +} + +./bin/kubectl create namespace spire + +# CRD has to be Established before any Kustomization CR (used by workloads.yaml) is applied. +./bin/kubectl apply -f ./conf/flux-kustomization-crd.yaml +./bin/kubectl wait --for=condition=Established --timeout=60s \ + crd/kustomizations.kustomize.toolkit.fluxcd.io + +./bin/kubectl apply -k ./conf/server +wait-for-rollout spire deployment/spire-server + +./bin/kubectl apply -k ./conf/agent +wait-for-rollout spire daemonset/spire-agent + +./bin/kubectl apply -f ./conf/workloads.yaml +wait-for-pod-ready spire target-pod +wait-for-pod-ready spire same-node-target-pod +wait-for-pod-ready spire other-node-target-pod +wait-for-pod-ready spire broker +wait-for-pod-ready spire cluster-broker +wait-for-pod-ready spire agent-node-broker +wait-for-pod-ready spire restricted-broker +wait-for-pod-ready spire rbac-denied-broker +wait-for-pod-ready spire unauthorized-workload diff --git a/test/integration/suites/broker-api-k8s/02-create-entries b/test/integration/suites/broker-api-k8s/02-create-entries new file mode 100755 index 0000000000..55e0df0450 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/02-create-entries @@ -0,0 +1,58 @@ +#!/bin/bash + +set -e + +source init-kubectl + +readarray -t NODEUIDS < <(./bin/kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.uid}{"\n"}{end}') +PARENTS=() +for nodeUID in "${NODEUIDS[@]}"; do + if [ -n "${nodeUID}" ]; then + PARENTS+=("spiffe://example.org/spire/agent/k8s_psat/example-cluster/${nodeUID}") + fi +done + +if [ "${#PARENTS[@]}" -eq 0 ]; then + fail-now "no Kubernetes nodes found for agent parent IDs" +fi + +create-entry() { + local spiffeID=$1; shift + local parent + for parent in "${PARENTS[@]}"; do + log-info "creating entry ${spiffeID} under ${parent}..." + ./bin/kubectl -nspire exec -t deployment/spire-server -- \ + /opt/spire/bin/spire-server entry create \ + -spiffeID "${spiffeID}" \ + -parentID "${parent}" \ + "$@" + done +} + +# Brokers — registered by their pod-name selectors. +create-entry "spiffe://example.org/broker" -selector "k8s:pod-name:broker" +create-entry "spiffe://example.org/cluster-broker" -selector "k8s:pod-name:cluster-broker" +create-entry "spiffe://example.org/agent-node-broker" -selector "k8s:pod-name:agent-node-broker" +create-entry "spiffe://example.org/local-only-object-broker" -selector "k8s:pod-name:local-only-object-broker" +create-entry "spiffe://example.org/restricted-broker" -selector "k8s:pod-name:restricted-broker" +create-entry "spiffe://example.org/rbac-denied-broker" -selector "k8s:pod-name:rbac-denied-broker" +create-entry "spiffe://example.org/unauthorized-workload" -selector "k8s:pod-name:unauthorized-workload" + +# Target pods — matched by the legacy PID path and/or by the +# KubernetesObjectReference(pods/core) path. The k8s attestor emits +# `pod-name` in both cases. +create-entry "spiffe://example.org/target-pod" -selector "k8s:pod-name:target-pod" +create-entry "spiffe://example.org/same-node-target-pod" -selector "k8s:pod-name:same-node-target-pod" +create-entry "spiffe://example.org/other-node-target-pod" -selector "k8s:pod-name:other-node-target-pod" + +# Target Kustomization — matched by the uniform object-meta selector +# vocabulary emitted by getSelectorValuesFromObjectMeta. +create-entry "spiffe://example.org/target-kustomization" \ + -selector "k8s:resource:kustomizations.kustomize.toolkit.fluxcd.io" \ + -selector "k8s:namespace:spire" \ + -selector "k8s:name:target-kustomization" + +# Give the agent a moment to sync entries before the test steps invoke +# brokerclient (the binary will also retry internally via the workloadapi +# source, so this is just a fast-path comfort wait). +sleep 15 diff --git a/test/integration/suites/broker-api-k8s/03-test-broker-by-pid b/test/integration/suites/broker-api-k8s/03-test-broker-by-pid new file mode 100755 index 0000000000..0818500498 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/03-test-broker-by-pid @@ -0,0 +1,32 @@ +#!/bin/bash + +set -e + +source init-kubectl + +log-info "Test: broker fetches target-pod SVID via WorkloadPIDReference" + +# Resolve the target pod's PID from the node (hostPID exposes it). The target +# container runs `spire-agent api watch` as PID 1 inside its namespace, but on +# the host we need its real PID. Get the container's pid via crictl-like +# path: read the pod-uid, look up the pid via /proc on the node. +TARGET_POD_UID=$(./bin/kubectl -nspire get pod target-pod -o jsonpath='{.metadata.uid}') + +# Use the broker pod (which has hostPID) to scan /proc for the container. +# The brokerclient's own PID inside the pod IS the host PID because hostPID +# is true. Easiest: ps -ef on the broker pod and grep for the target command. +TARGET_PID=$(./bin/kubectl -nspire exec -t broker -- sh -c \ + "ps -ef | grep '[s]pire-agent api watch' | awk '{print \$1}' | head -n1") + +if [ -z "${TARGET_PID}" ]; then + fail-now "could not resolve target-pod PID from broker container" +fi +log-info "target-pod PID on host: ${TARGET_PID}" + +./bin/kubectl -nspire exec -t broker -- /brokerclient \ + -ref-type pid \ + -pid "${TARGET_PID}" \ + -expected-spiffe spiffe://example.org/target-pod \ + || fail-now "broker failed to fetch SVID via PID reference" + +log-success "broker fetched target-pod SVID via WorkloadPIDReference" diff --git a/test/integration/suites/broker-api-k8s/04-test-broker-by-pod-obj b/test/integration/suites/broker-api-k8s/04-test-broker-by-pod-obj new file mode 100755 index 0000000000..b7b7f2663b --- /dev/null +++ b/test/integration/suites/broker-api-k8s/04-test-broker-by-pod-obj @@ -0,0 +1,32 @@ +#!/bin/bash + +set -e + +source init-kubectl + +log-info "Test: broker fetches target-pod SVID via KubernetesObjectReference (pods/core) by name" + +./bin/kubectl -nspire exec -t broker -- /brokerclient \ + -ref-type object \ + -plural pods -group core \ + -namespace spire -name target-pod \ + -expected-spiffe spiffe://example.org/target-pod \ + || fail-now "broker failed to fetch SVID via pod object reference by name" + +log-info "Test: broker fetches target-pod SVID via KubernetesObjectReference (pods/core) by UID" + +# This mirrors the Istio ztunnel shape: the broker connects over the local UDS +# and the k8s workload attestor uses the default pod_reference_scope=agent_node. +TARGET_POD_UID=$(./bin/kubectl -nspire get pod target-pod -o jsonpath='{.metadata.uid}') +if [ -z "${TARGET_POD_UID}" ]; then + fail-now "could not resolve target-pod UID" +fi + +./bin/kubectl -nspire exec -t broker -- /brokerclient \ + -ref-type object \ + -plural pods -group core \ + -uid "${TARGET_POD_UID}" \ + -expected-spiffe spiffe://example.org/target-pod \ + || fail-now "broker failed to fetch SVID via pod UID object reference" + +log-success "broker fetched target-pod SVID via KubernetesObjectReference by name and UID" diff --git a/test/integration/suites/broker-api-k8s/04-test-broker-pod-reference-scopes b/test/integration/suites/broker-api-k8s/04-test-broker-pod-reference-scopes new file mode 100755 index 0000000000..5e7b3b6ce3 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/04-test-broker-pod-reference-scopes @@ -0,0 +1,79 @@ +#!/bin/bash + +set -e + +source init-kubectl + +SAME_NODE_TARGET_POD_UID=$(./bin/kubectl -nspire get pod same-node-target-pod -o jsonpath='{.metadata.uid}') +OTHER_NODE_TARGET_POD_UID=$(./bin/kubectl -nspire get pod other-node-target-pod -o jsonpath='{.metadata.uid}') +if [ -z "${SAME_NODE_TARGET_POD_UID}" ]; then + fail-now "could not resolve same-node-target-pod UID" +fi +if [ -z "${OTHER_NODE_TARGET_POD_UID}" ]; then + fail-now "could not resolve other-node-target-pod UID" +fi + +log-info "Test: cluster-scoped broker fetches same-node pod SVID by name" + +./bin/kubectl -nspire exec -t cluster-broker -- /brokerclient \ + -ref-type object \ + -plural pods -group core \ + -namespace spire -name same-node-target-pod \ + -expected-spiffe spiffe://example.org/same-node-target-pod \ + || fail-now "cluster-scoped broker failed to fetch same-node pod SVID by name" + +log-info "Test: agent-node-scoped broker fetches same-node pod SVID by name" + +./bin/kubectl -nspire exec -t agent-node-broker -- /brokerclient \ + -ref-type object \ + -plural pods -group core \ + -namespace spire -name same-node-target-pod \ + -expected-spiffe spiffe://example.org/same-node-target-pod \ + || fail-now "agent-node-scoped broker failed to fetch same-node pod SVID by name" + +log-info "Test: agent-node-scoped broker fetches same-node pod SVID by UID" + +./bin/kubectl -nspire exec -t agent-node-broker -- /brokerclient \ + -ref-type object \ + -plural pods -group core \ + -uid "${SAME_NODE_TARGET_POD_UID}" \ + -expected-spiffe spiffe://example.org/same-node-target-pod \ + || fail-now "agent-node-scoped broker failed to fetch same-node pod SVID by UID" + +log-info "Test: cluster-scoped broker fetches other-node pod SVID by name" + +./bin/kubectl -nspire exec -t cluster-broker -- /brokerclient \ + -ref-type object \ + -plural pods -group core \ + -namespace spire -name other-node-target-pod \ + -expected-spiffe spiffe://example.org/other-node-target-pod \ + || fail-now "cluster-scoped broker failed to fetch other-node pod SVID by name" + +log-info "Test: cluster-scoped broker fetches other-node pod SVID by UID" + +./bin/kubectl -nspire exec -t cluster-broker -- /brokerclient \ + -ref-type object \ + -plural pods -group core \ + -uid "${OTHER_NODE_TARGET_POD_UID}" \ + -expected-spiffe spiffe://example.org/other-node-target-pod \ + || fail-now "cluster-scoped broker failed to fetch other-node pod SVID by UID" + +log-info "Test: agent-node-scoped broker cannot fetch other-node pod SVID by name" + +./bin/kubectl -nspire exec -t agent-node-broker -- /brokerclient \ + -ref-type object \ + -plural pods -group core \ + -namespace spire -name other-node-target-pod \ + -expect-err NotFound \ + || fail-now "agent-node-scoped broker did not get NotFound for other-node pod SVID by name" + +log-info "Test: agent-node-scoped broker cannot fetch other-node pod SVID by UID" + +./bin/kubectl -nspire exec -t agent-node-broker -- /brokerclient \ + -ref-type object \ + -plural pods -group core \ + -uid "${OTHER_NODE_TARGET_POD_UID}" \ + -expect-err NotFound \ + || fail-now "agent-node-scoped broker did not get NotFound for other-node pod SVID by UID" + +log-success "pod reference scope scenarios behaved as expected by name and UID" diff --git a/test/integration/suites/broker-api-k8s/05-test-broker-by-flux-kustomization b/test/integration/suites/broker-api-k8s/05-test-broker-by-flux-kustomization new file mode 100755 index 0000000000..d3f9f9c1b7 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/05-test-broker-by-flux-kustomization @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +source init-kubectl + +log-info "Test: broker fetches Flux Kustomization SVID via KubernetesObjectReference" + +./bin/kubectl -nspire exec -t broker -- /brokerclient \ + -ref-type object \ + -plural kustomizations -group kustomize.toolkit.fluxcd.io \ + -namespace spire -name target-kustomization \ + -expected-spiffe spiffe://example.org/target-kustomization \ + || fail-now "broker failed to fetch SVID for Flux Kustomization" + +log-success "broker fetched Flux Kustomization SVID via KubernetesObjectReference" diff --git a/test/integration/suites/broker-api-k8s/06-test-restricted-broker-denied b/test/integration/suites/broker-api-k8s/06-test-restricted-broker-denied new file mode 100755 index 0000000000..b60bb81075 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/06-test-restricted-broker-denied @@ -0,0 +1,33 @@ +#!/bin/bash + +set -e + +source init-kubectl + +log-info "Test: restricted-broker (PID-only allowlist) is denied for object reference" + +./bin/kubectl -nspire exec -t restricted-broker -- /brokerclient \ + -ref-type object \ + -plural pods -group core \ + -namespace spire -name target-pod \ + -expect-err PermissionDenied \ + || fail-now "restricted-broker did not get PermissionDenied for object reference" + +log-success "restricted-broker rejected with PermissionDenied as expected" + +log-info "Test: restricted-broker IS still allowed to use PID reference" + +TARGET_PID=$(./bin/kubectl -nspire exec -t restricted-broker -- sh -c \ + "ps -ef | grep '[s]pire-agent api watch' | awk '{print \$1}' | head -n1") + +if [ -z "${TARGET_PID}" ]; then + fail-now "could not resolve target-pod PID from restricted-broker" +fi + +./bin/kubectl -nspire exec -t restricted-broker -- /brokerclient \ + -ref-type pid \ + -pid "${TARGET_PID}" \ + -expected-spiffe spiffe://example.org/target-pod \ + || fail-now "restricted-broker failed PID reference within its allowlist" + +log-success "restricted-broker successfully used the reference type within its allowlist" diff --git a/test/integration/suites/broker-api-k8s/07-test-broker-rbac-denied b/test/integration/suites/broker-api-k8s/07-test-broker-rbac-denied new file mode 100755 index 0000000000..2fd757b466 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/07-test-broker-rbac-denied @@ -0,0 +1,131 @@ +#!/bin/bash + +set -e + +source init-kubectl + +AGENT_MANIFEST=./conf/agent/spire-agent.yaml +MUTATED_AGENT_CONFIG=false + +wait-for-agent-rollout() { + MAXROLLOUTCHECKS=12 + ROLLOUTCHECKINTERVAL=15s + for ((i=0; i<${MAXROLLOUTCHECKS}; i++)); do + log-info "checking rollout status for spire daemonset/spire-agent..." + if ./bin/kubectl -nspire rollout status daemonset/spire-agent --timeout="${ROLLOUTCHECKINTERVAL}"; then + return + fi + log-warn "describing spire daemonset/spire-agent..." + ./bin/kubectl -nspire describe daemonset/spire-agent || true + log-warn "logs for spire-agent pods..." + ./bin/kubectl -nspire logs -l app=spire-agent --all-containers --tail=-1 || true + done + fail-now "Failed waiting for daemonset/spire-agent to roll out." +} + +restart-agent-and-wait() { + ./bin/kubectl -nspire rollout restart daemonset/spire-agent + wait-for-agent-rollout +} + +apply-agent-config-with-access-policy() { + local policy=$1 + log-info "setting k8s broker access_policy to ${policy}" + sed "s/access_policy = \"enforced\"/access_policy = \"${policy}\"/" "${AGENT_MANIFEST}" | ./bin/kubectl apply -f - + MUTATED_AGENT_CONFIG=true + restart-agent-and-wait +} + +apply-agent-config-without-access-policy() { + log-info "removing k8s broker access_policy" + sed '/access_policy = "enforced"/d' "${AGENT_MANIFEST}" | ./bin/kubectl apply -f - + MUTATED_AGENT_CONFIG=true + ./bin/kubectl -nspire rollout restart daemonset/spire-agent +} + +restore-agent-config() { + if [ "${MUTATED_AGENT_CONFIG}" != true ]; then + return + fi + log-info "restoring k8s broker access_policy to enforced" + ./bin/kubectl apply -f "${AGENT_MANIFEST}" || true + ./bin/kubectl -nspire rollout restart daemonset/spire-agent || true + ./bin/kubectl -nspire rollout status daemonset/spire-agent --timeout=180s || true +} + +trap restore-agent-config EXIT + +assert-agent-logs-contain() { + local needle=$1 + local logs + + logs=$(./bin/kubectl -nspire logs -l app=spire-agent --all-containers --tail=-1 2>&1 || true) + if echo "${logs}" | grep -F "${needle}" >/dev/null; then + return + fi + + logs=$(./bin/kubectl -nspire logs -l app=spire-agent --all-containers --previous --tail=-1 2>&1 || true) + if echo "${logs}" | grep -F "${needle}" >/dev/null; then + return + fi + + ./bin/kubectl -nspire describe pods -l app=spire-agent || true + fail-now "spire-agent logs did not contain expected text: ${needle}" +} + +log-info "Test: access_policy=enforced denies a broker lacking Kubernetes RBAC" + +./bin/kubectl -nspire exec -t rbac-denied-broker -- /brokerclient \ + -ref-type object \ + -plural pods -group core \ + -namespace spire -name target-pod \ + -expect-err PermissionDenied \ + || fail-now "rbac-denied-broker did not get PermissionDenied from Kubernetes authorization" + +log-success "rbac-denied-broker rejected by Kubernetes authorization as expected" + +log-info "Test: access_policy=enforced denies PID references for a broker lacking Kubernetes RBAC" + +TARGET_PID=$(./bin/kubectl -nspire exec -t rbac-denied-broker -- sh -c \ + "ps -ef | grep '[s]pire-agent api watch' | awk '{print \$1}' | head -n1") + +if [ -z "${TARGET_PID}" ]; then + fail-now "could not resolve target-pod PID from rbac-denied-broker" +fi + +./bin/kubectl -nspire exec -t rbac-denied-broker -- /brokerclient \ + -ref-type pid \ + -pid "${TARGET_PID}" \ + -expect-err PermissionDenied \ + || fail-now "rbac-denied-broker PID reference did not get PermissionDenied from Kubernetes authorization" + +log-success "rbac-denied-broker PID reference rejected by Kubernetes authorization as expected" + +apply-agent-config-with-access-policy permissive + +log-info "Test: access_policy=permissive allows a broker lacking Kubernetes RBAC" + +./bin/kubectl -nspire exec -t rbac-denied-broker -- /brokerclient \ + -ref-type object \ + -plural pods -group core \ + -namespace spire -name target-pod \ + -expected-spiffe spiffe://example.org/target-pod \ + || fail-now "rbac-denied-broker failed under permissive access policy" + +log-success "rbac-denied-broker succeeded under permissive access policy" + +apply-agent-config-without-access-policy + +log-info "Test: missing experimental.broker.access_policy prevents the k8s workload attestor from starting" + +if ./bin/kubectl -nspire rollout status daemonset/spire-agent --timeout=60s; then + fail-now "spire-agent rolled out successfully without experimental.broker.access_policy" +fi + +assert-agent-logs-contain "experimental.broker.access_policy: must be specified as one of [permissive, enforced]" + +log-success "spire-agent rejected missing experimental.broker.access_policy as expected" + +restore-agent-config +MUTATED_AGENT_CONFIG=false +trap - EXIT diff --git a/test/integration/suites/broker-api-k8s/07-test-unauthorized-workload-rejected b/test/integration/suites/broker-api-k8s/07-test-unauthorized-workload-rejected new file mode 100755 index 0000000000..80fc3e1856 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/07-test-unauthorized-workload-rejected @@ -0,0 +1,26 @@ +#!/bin/bash + +set -e + +source init-kubectl + +log-info "Test: unauthorized workload CAN still fetch its own SVID via Workload API" + +./bin/kubectl -nspire exec -t unauthorized-workload -- /brokerclient \ + -skip-broker \ + || fail-now "unauthorized workload should still be able to use the Workload API" + +log-success "unauthorized workload fetched its own SVID via Workload API" + +log-info "Test: unauthorized workload is REJECTED at mTLS when dialing the broker endpoint" + +# Its SPIFFE ID is not in the agent's broker allowlist, so the server's mTLS +# authorizer fails the cert verification. gRPC surfaces this as Unavailable. +./bin/kubectl -nspire exec -t unauthorized-workload -- /brokerclient \ + -ref-type object \ + -plural pods -group core \ + -namespace spire -name target-pod \ + -expect-err Unavailable \ + || fail-now "unauthorized workload was not rejected at mTLS layer" + +log-success "unauthorized workload rejected at mTLS layer as expected" diff --git a/test/integration/suites/broker-api-k8s/08-test-broker-by-tcp b/test/integration/suites/broker-api-k8s/08-test-broker-by-tcp new file mode 100755 index 0000000000..d46c433d30 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/08-test-broker-by-tcp @@ -0,0 +1,21 @@ +#!/bin/bash + +set -e + +source init-kubectl + +log-info "Test: broker fetches Flux Kustomization SVID via the TCP broker listener" + +# The broker pod normally dials the agent over the local UDS. For this +# step we override -broker-addr to point at the spire-broker ClusterIP +# Service, which fronts the agent's TCP listener on port 8443. mTLS, +# allowlist, and reference-type semantics are identical to the UDS path. +./bin/kubectl -nspire exec -t broker -- /brokerclient \ + -broker-addr dns:///spire-broker:8443 \ + -ref-type object \ + -plural kustomizations -group kustomize.toolkit.fluxcd.io \ + -namespace spire -name target-kustomization \ + -expected-spiffe spiffe://example.org/target-kustomization \ + || fail-now "broker failed to fetch SVID over TCP" + +log-success "broker fetched SVID over TCP via the spire-broker Service" diff --git a/test/integration/suites/broker-api-k8s/09-test-broker-object-over-tcp-denied b/test/integration/suites/broker-api-k8s/09-test-broker-object-over-tcp-denied new file mode 100755 index 0000000000..be8c05e73e --- /dev/null +++ b/test/integration/suites/broker-api-k8s/09-test-broker-object-over-tcp-denied @@ -0,0 +1,31 @@ +#!/bin/bash + +set -e + +source init-kubectl + +log-info "Test: KubernetesObjectReference over UDS succeeds for local-only object broker" + +TARGET_POD_UID=$(./bin/kubectl -nspire get pod target-pod -o jsonpath='{.metadata.uid}') +if [ -z "${TARGET_POD_UID}" ]; then + fail-now "could not resolve target-pod UID" +fi + +./bin/kubectl -nspire exec -t local-only-object-broker -- /brokerclient \ + -ref-type object \ + -plural pods -group core \ + -uid "${TARGET_POD_UID}" \ + -expected-spiffe spiffe://example.org/target-pod \ + || fail-now "local-only object broker failed to fetch pod SVID over UDS" + +log-info "Test: KubernetesObjectReference over TCP is denied without allow_over_tcp" + +./bin/kubectl -nspire exec -t local-only-object-broker -- /brokerclient \ + -broker-addr dns:///spire-broker:8443 \ + -ref-type object \ + -plural pods -group core \ + -uid "${TARGET_POD_UID}" \ + -expect-err PermissionDenied \ + || fail-now "KubernetesObjectReference-over-TCP was not denied" + +log-success "KubernetesObjectReference over TCP rejected with PermissionDenied" diff --git a/test/integration/suites/broker-api-k8s/09-test-broker-pid-over-tcp-denied b/test/integration/suites/broker-api-k8s/09-test-broker-pid-over-tcp-denied new file mode 100755 index 0000000000..58f51f5d02 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/09-test-broker-pid-over-tcp-denied @@ -0,0 +1,25 @@ +#!/bin/bash + +set -e + +source init-kubectl + +log-info "Test: PID reference over TCP is denied by allow_over_tcp" + +# The broker permits PID references over UDS but does not set allow_over_tcp +# for that reference type. A PID reference passed over the TCP listener must +# be rejected with PermissionDenied. +TARGET_PID=$(./bin/kubectl -nspire exec -t broker -- sh -c \ + "ps -ef | grep '[s]pire-agent api watch' | awk '{print \$1}' | head -n1") + +if [ -z "${TARGET_PID}" ]; then + fail-now "could not resolve target-pod PID from broker" +fi + +./bin/kubectl -nspire exec -t broker -- /brokerclient \ + -broker-addr dns:///spire-broker:8443 \ + -ref-type pid -pid "${TARGET_PID}" \ + -expect-err PermissionDenied \ + || fail-now "PID-over-TCP was not denied" + +log-success "PID reference over TCP rejected with PermissionDenied" diff --git a/test/integration/suites/broker-api-k8s/README.md b/test/integration/suites/broker-api-k8s/README.md new file mode 100644 index 0000000000..0629f2a662 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/README.md @@ -0,0 +1,50 @@ +# SPIFFE Broker API (Kubernetes) Suite + +## Description + +Spins up a KIND cluster and exercises the SPIFFE Broker API end-to-end against +real workloads. Covers: + +* A broker fetches a pod workload's SVID via `WorkloadPIDReference` (legacy + PID-based path). +* A broker fetches a pod workload's SVID via `KubernetesObjectReference` + (`pods/core`) by both name and UID. The UID case uses the default UDS broker + endpoint and the default `pod_reference_scope = "agent_node"`, matching the + local ztunnel-style lookup shape. +* Cluster-scoped and agent-node-scoped brokers fetch same-node pod SVIDs, while + only the cluster-scoped broker can fetch an other-node pod SVID. The + agent-node-scoped broker gets `NotFound` for other-node pods because it only + uses the local kubelet pod list. These scope checks are exercised by both pod + name and pod UID. +* A broker fetches a **non-pod** object's SVID via `KubernetesObjectReference` + (`kustomizations.kustomize.toolkit.fluxcd.io`) — exercises the generic + object-attestation path that resolves the resource via the REST mapper and + emits the uniform object-meta selector vocabulary. +* A broker whose `allowed_reference_types` is restricted to PID references + gets `PermissionDenied` at the gRPC layer when it asks for a + `KubernetesObjectReference`. +* With `experimental.broker.access_policy = "enforced"`, a broker whose `allowed_reference_types` + permits PID and `KubernetesObjectReference` requests, but whose broker SPIFFE + ID lacks `impersonate-via-spire` authorization, gets `PermissionDenied` from + the k8s attestor's `SubjectAccessReview`. +* With `experimental.broker.access_policy = "permissive"`, that same broker can fetch the + referenced workload SVID without Kubernetes `impersonate-via-spire` RBAC. +* If the k8s workload attestor `experimental.broker` block omits `access_policy`, the SPIRE + agent plugin configuration fails and the agent does not roll out. +* A workload that is not in the agent's broker allowlist can still use the + Workload API to fetch its own SVID, but is rejected at the mTLS layer when + it tries to dial the broker endpoint as a broker. +* A broker can also reach the agent over the **TCP** broker listener (via a + ClusterIP Service in front of the agent daemonset), proving the same mTLS + and reference-type semantics apply to remote-style brokers. +* Per-reference `allow_over_tcp` denies PID and Kubernetes object references + over TCP unless the specific reference type explicitly opts in - fail-closed + against remote use of local-only reference types. + +Only the Flux Kustomization CRD is installed (no controllers); the resource +just needs to exist in the API server so the broker can reference it. + +The agent RBAC fixture grants `create` on +`subjectaccessreviews.authorization.k8s.io` because this suite opts in with +`experimental.broker.access_policy = "enforced"` and the k8s workload attestor uses +SubjectAccessReview API calls to authorize Broker API reference requests. diff --git a/test/integration/suites/broker-api-k8s/conf/agent/kustomization.yaml b/test/integration/suites/broker-api-k8s/conf/agent/kustomization.yaml new file mode 100644 index 0000000000..17d0a0d8e3 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/conf/agent/kustomization.yaml @@ -0,0 +1,10 @@ +# kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# list of Resource Config to be Applied +resources: +- spire-agent.yaml + +# namespace to deploy all Resources to +namespace: spire diff --git a/test/integration/suites/broker-api-k8s/conf/agent/spire-agent.yaml b/test/integration/suites/broker-api-k8s/conf/agent/spire-agent.yaml new file mode 100644 index 0000000000..e9cad699ff --- /dev/null +++ b/test/integration/suites/broker-api-k8s/conf/agent/spire-agent.yaml @@ -0,0 +1,340 @@ +# ServiceAccount for the SPIRE agent +apiVersion: v1 +kind: ServiceAccount +metadata: + name: spire-agent + namespace: spire + +--- +# Cluster role granting the agent permission to: +# - read pods/nodes for the legacy PID/k8s_psat path +# - read Kustomization CRs so the broker API can attest non-pod object references +# - create SubjectAccessReviews for broker authorization checks +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-agent-cluster-role +rules: + - apiGroups: [""] + resources: ["pods", "nodes", "nodes/proxy"] + verbs: ["get", "list"] + - apiGroups: ["kustomize.toolkit.fluxcd.io"] + resources: ["kustomizations"] + verbs: ["get", "list"] + - apiGroups: ["authorization.k8s.io"] + resources: ["subjectaccessreviews"] + verbs: ["create"] + +--- +# Cluster role used by the k8s workload attestor's SubjectAccessReview check. +# The broker SPIFFE ID is used as the SubjectAccessReview username. +# Use SPIRE's custom verb, not Kubernetes' built-in `impersonate` verb. +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-broker-impersonation-role +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["impersonate-via-spire"] + - apiGroups: ["kustomize.toolkit.fluxcd.io"] + resources: ["kustomizations"] + verbs: ["impersonate-via-spire"] + +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-broker-impersonation-role-binding +subjects: + - kind: User + name: spiffe://example.org/broker + - kind: User + name: spiffe://example.org/cluster-broker + - kind: User + name: spiffe://example.org/agent-node-broker + - kind: User + name: spiffe://example.org/local-only-object-broker + - kind: User + name: spiffe://example.org/restricted-broker +roleRef: + kind: ClusterRole + name: spire-broker-impersonation-role + apiGroup: rbac.authorization.k8s.io + +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-agent-cluster-role-binding +subjects: + - kind: ServiceAccount + name: spire-agent + namespace: spire +roleRef: + kind: ClusterRole + name: spire-agent-cluster-role + apiGroup: rbac.authorization.k8s.io + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-agent + namespace: spire +data: + agent.conf: | + agent { + data_dir = "/run/spire" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + socket_path = "/run/spire/agent-sockets/api.sock" + trust_bundle_path = "/run/spire/bundle/bundle.crt" + trust_domain = "example.org" + + experimental { + # Enable the SPIFFE Broker API endpoint on both a UDS (for local + # brokers) and a TCP listener (for remote brokers). The TCP port + # is fronted by the spire-broker Service. Each authorized broker + # presents a SPIFFE ID; mTLS at the listener gates the set. + broker { + socket_path = "/run/spire/broker-sockets/broker.sock" + bind_address = "0.0.0.0:8443" + # Fail closed for TCP except KubernetesObjectReference entries that + # explicitly opt in with allow_over_tcp. + brokers = [ + { + id = "spiffe://example.org/broker" + allowed_reference_types = [ + { + type_url = "type.googleapis.com/spiffe.broker.WorkloadPIDReference" + }, + { + type_url = "type.googleapis.com/spiffe.broker.KubernetesObjectReference" + allow_over_tcp = true + }, + ] + }, + { + id = "spiffe://example.org/cluster-broker" + allowed_reference_types = [ + { + type_url = "type.googleapis.com/spiffe.broker.KubernetesObjectReference" + allow_over_tcp = true + }, + ] + }, + { + id = "spiffe://example.org/agent-node-broker" + allowed_reference_types = [ + { + type_url = "type.googleapis.com/spiffe.broker.KubernetesObjectReference" + allow_over_tcp = true + }, + ] + }, + { + id = "spiffe://example.org/local-only-object-broker" + allowed_reference_types = [ + { + type_url = "type.googleapis.com/spiffe.broker.KubernetesObjectReference" + }, + ] + }, + { + id = "spiffe://example.org/restricted-broker" + allowed_reference_types = [ + { + type_url = "type.googleapis.com/spiffe.broker.WorkloadPIDReference" + }, + ] + }, + { + id = "spiffe://example.org/rbac-denied-broker" + allowed_reference_types = [ + { + type_url = "type.googleapis.com/spiffe.broker.WorkloadPIDReference" + }, + { + type_url = "type.googleapis.com/spiffe.broker.KubernetesObjectReference" + allow_over_tcp = true + }, + ] + }, + ] + } + } + } + + plugins { + NodeAttestor "k8s_psat" { + plugin_data { + cluster = "example-cluster" + } + } + + KeyManager "memory" { + plugin_data { + } + } + + WorkloadAttestor "k8s" { + plugin_data { + # Skip kubelet cert verification (kind doesn't ship a CA bundle entry for it). + skip_kubelet_verification = true + node_name_env = "MY_NODE_NAME" + + experimental { + api_server { + cache { + enabled = true + } + } + + # Broker-specific Kubernetes authorization config. + # access_policy = "enforced" opts in to SubjectAccessReview checks. + # The broker SPIFFE ID is used as the SubjectAccessReview username. + broker { + access_policy = "enforced" + brokers = [ + { + id = "spiffe://example.org/broker" + }, + { + id = "spiffe://example.org/cluster-broker" + pod_reference_scope = "cluster" + }, + { + id = "spiffe://example.org/agent-node-broker" + pod_reference_scope = "agent_node" + }, + { + id = "spiffe://example.org/local-only-object-broker" + }, + { + id = "spiffe://example.org/restricted-broker" + }, + { + id = "spiffe://example.org/rbac-denied-broker" + }, + ] + } + } + } + } + } + + health_checks { + listener_enabled = true + bind_address = "0.0.0.0" + bind_port = "8080" + live_path = "/live" + ready_path = "/ready" + } + +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: spire-agent + namespace: spire + labels: + app: spire-agent +spec: + selector: + matchLabels: + app: spire-agent + updateStrategy: + type: RollingUpdate + template: + metadata: + namespace: spire + labels: + app: spire-agent + spec: + hostPID: true + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + serviceAccountName: spire-agent + containers: + - name: spire-agent + image: spire-agent:latest-local + imagePullPolicy: Never + args: ["-config", "/run/spire/config/agent.conf"] + env: + - name: MY_NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + ports: + - name: broker + containerPort: 8443 + volumeMounts: + - name: spire-config + mountPath: /run/spire/config + readOnly: true + - name: spire-bundle + mountPath: /run/spire/bundle + readOnly: true + - name: spire-agent-socket + mountPath: /run/spire/agent-sockets + readOnly: false + - name: spire-broker-socket + mountPath: /run/spire/broker-sockets + readOnly: false + - name: spire-token + mountPath: /var/run/secrets/tokens + livenessProbe: + httpGet: + path: /live + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + volumes: + - name: spire-config + configMap: + name: spire-agent + - name: spire-bundle + configMap: + name: spire-bundle + - name: spire-agent-socket + hostPath: + path: /run/spire/agent-sockets + type: DirectoryOrCreate + - name: spire-broker-socket + hostPath: + path: /run/spire/broker-sockets + type: DirectoryOrCreate + - name: spire-token + projected: + sources: + - serviceAccountToken: + path: spire-agent + expirationSeconds: 7200 + audience: spire-server + +--- +# Fronts the agent's broker TCP listener so remote-style brokers can reach +# it without depending on hostNetwork or the node IP. Targets the daemonset +# pods directly via the `app=spire-agent` selector. +apiVersion: v1 +kind: Service +metadata: + name: spire-broker + namespace: spire +spec: + type: ClusterIP + selector: + app: spire-agent + ports: + - name: broker + port: 8443 + targetPort: 8443 + protocol: TCP diff --git a/test/integration/suites/broker-api-k8s/conf/broker-client.Dockerfile b/test/integration/suites/broker-api-k8s/conf/broker-client.Dockerfile new file mode 100644 index 0000000000..dd7a86eca2 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/conf/broker-client.Dockerfile @@ -0,0 +1,5 @@ +# Tiny image that just carries the brokerclient binary built on the host. +# We base on busybox so `sleep infinity` is available; the suite scripts +# `kubectl exec` into the pod and invoke /brokerclient with per-test flags. +FROM busybox:1.37 +COPY brokerclient /brokerclient diff --git a/test/integration/suites/broker-api-k8s/conf/flux-kustomization-crd.yaml b/test/integration/suites/broker-api-k8s/conf/flux-kustomization-crd.yaml new file mode 100644 index 0000000000..102c9a3117 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/conf/flux-kustomization-crd.yaml @@ -0,0 +1,25 @@ +# Minimal stand-in for the Flux source-controller Kustomization CRD. +# The broker API doesn't need the Flux controller running — only the CRD has +# to be registered so the agent's REST mapper can resolve +# `kustomizations.kustomize.toolkit.fluxcd.io` and fetch PartialObjectMetadata. +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: kustomizations.kustomize.toolkit.fluxcd.io +spec: + group: kustomize.toolkit.fluxcd.io + scope: Namespaced + names: + kind: Kustomization + plural: kustomizations + singular: kustomization + shortNames: + - ks + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + x-kubernetes-preserve-unknown-fields: true diff --git a/test/integration/suites/broker-api-k8s/conf/kind-config.yaml b/test/integration/suites/broker-api-k8s/conf/kind-config.yaml new file mode 100644 index 0000000000..ae8cd3041f --- /dev/null +++ b/test/integration/suites/broker-api-k8s/conf/kind-config.yaml @@ -0,0 +1,6 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: + - role: control-plane + - role: worker + - role: worker diff --git a/test/integration/suites/broker-api-k8s/conf/server/kustomization.yaml b/test/integration/suites/broker-api-k8s/conf/server/kustomization.yaml new file mode 100644 index 0000000000..61ec1abdc4 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/conf/server/kustomization.yaml @@ -0,0 +1,10 @@ +# kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# list of Resource Config to be Applied +resources: +- spire-server.yaml + +# namespace to deploy all Resources to +namespace: spire diff --git a/test/integration/suites/broker-api-k8s/conf/server/spire-server.yaml b/test/integration/suites/broker-api-k8s/conf/server/spire-server.yaml new file mode 100644 index 0000000000..bad6a2a7b0 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/conf/server/spire-server.yaml @@ -0,0 +1,235 @@ +# ServiceAccount used by the SPIRE server. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: spire-server + namespace: spire + +--- + +# Required cluster role to allow spire-server to query k8s API server +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-server-cluster-role +rules: +- apiGroups: [""] + resources: ["pods", "nodes"] + verbs: ["get", "list", "watch"] + # allow TokenReview requests (to verify service account tokens for PSAT + # attestation) +- apiGroups: ["authentication.k8s.io"] + resources: ["tokenreviews"] + verbs: ["get", "create"] + +--- + +# Binds above cluster role to spire-server service account +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-server-cluster-role-binding + namespace: spire +subjects: +- kind: ServiceAccount + name: spire-server + namespace: spire +roleRef: + kind: ClusterRole + name: spire-server-cluster-role + apiGroup: rbac.authorization.k8s.io + +--- + +# Role for the SPIRE server +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + namespace: spire + name: spire-server-role +rules: + # allow "get" access to pods (to resolve selectors for PSAT attestation) +- apiGroups: [""] + resources: ["pods"] + verbs: ["get"] + # allow access to "get" and "patch" the spire-bundle ConfigMap (for SPIRE + # agent bootstrapping, see the spire-bundle ConfigMap below) +- apiGroups: [""] + resources: ["configmaps"] + resourceNames: ["spire-bundle"] + verbs: ["get", "patch"] +- apiGroups: [""] + resources: ["configmaps"] + verbs: ["create"] +- apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["create", "update", "get"] +- apiGroups: [""] + resources: ["events"] + verbs: ["create"] + +--- + +# RoleBinding granting the spire-server-role to the SPIRE server +# service account. +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-server-role-binding + namespace: spire +subjects: +- kind: ServiceAccount + name: spire-server + namespace: spire +roleRef: + kind: Role + name: spire-server-role + apiGroup: rbac.authorization.k8s.io + +--- + +# ConfigMap containing the latest trust bundle for the trust domain. It is +# updated by SPIRE using the k8sbundle notifier plugin. SPIRE agents mount +# this config map and use the certificate to bootstrap trust with the SPIRE +# server during attestation. +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-bundle + namespace: spire + +--- + +# ConfigMap containing the SPIRE server configuration. +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-server + namespace: spire +data: + server.conf: | + server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "example.org" + data_dir = "/run/spire/data" + log_level = "DEBUG" + default_x509_svid_ttl = "1h" + ca_ttl = "12h" + ca_subject { + country = ["US"] + organization = ["SPIFFE"] + common_name = "" + } + } + + plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/run/spire/data/datastore.sqlite3" + } + } + + NodeAttestor "k8s_psat" { + plugin_data { + clusters = { + "example-cluster" = { + service_account_allow_list = ["spire:spire-agent"] + } + } + } + } + + KeyManager "disk" { + plugin_data { + keys_path = "/run/spire/data/keys.json" + } + } + + Notifier "k8sbundle" { + plugin_data { + # This plugin updates the bundle.crt value in the spire:spire-bundle + # ConfigMap by default, so no additional configuration is necessary. + } + } + } + + health_checks { + listener_enabled = true + bind_address = "0.0.0.0" + bind_port = "8080" + live_path = "/live" + ready_path = "/ready" + } + +--- + +# This is the Deployment for the SPIRE server. It waits for SPIRE database to +# initialize and uses the SPIRE healthcheck command for liveness/readiness +# probes. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: spire-server + namespace: spire + labels: + app: spire-server +spec: + replicas: 1 + selector: + matchLabels: + app: spire-server + template: + metadata: + namespace: spire + labels: + app: spire-server + spec: + serviceAccountName: spire-server + shareProcessNamespace: true + containers: + - name: spire-server + image: spire-server:latest-local + imagePullPolicy: Never + args: ["-config", "/run/spire/config/server.conf"] + ports: + - containerPort: 8081 + volumeMounts: + - name: spire-config + mountPath: /run/spire/config + readOnly: true + livenessProbe: + httpGet: + path: /live + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + volumes: + - name: spire-config + configMap: + name: spire-server + +--- + +# Service definition for SPIRE server defining the gRPC port. +apiVersion: v1 +kind: Service +metadata: + name: spire-server + namespace: spire +spec: + type: NodePort + ports: + - name: grpc + port: 8081 + targetPort: 8081 + protocol: TCP + selector: + app: spire-server diff --git a/test/integration/suites/broker-api-k8s/conf/workloads.yaml b/test/integration/suites/broker-api-k8s/conf/workloads.yaml new file mode 100644 index 0000000000..69a2042e3a --- /dev/null +++ b/test/integration/suites/broker-api-k8s/conf/workloads.yaml @@ -0,0 +1,328 @@ +# The "target" workload — a regular pod the broker(s) will fetch SVIDs for +# via PID reference and via KubernetesObjectReference(pods/core). +# It runs `spire-agent api watch` so it stays alive with a steady PID. +apiVersion: v1 +kind: Pod +metadata: + name: target-pod + namespace: spire + labels: + app: target-pod +spec: + hostPID: true + nodeName: broker-api-k8s-worker + containers: + - name: target + image: spire-agent:latest-local + imagePullPolicy: Never + command: ["/opt/spire/bin/spire-agent", "api", "watch"] + args: ["-socketPath", "/run/spire/agent-sockets/api.sock"] + volumeMounts: + - name: spire-agent-socket + mountPath: /run/spire/agent-sockets + readOnly: false + volumes: + - name: spire-agent-socket + hostPath: + path: /run/spire/agent-sockets + type: Directory + +--- +# Pod on the same node as the scoped broker pods. It only needs to +# exist for KubernetesObjectReference lookups. +apiVersion: v1 +kind: Pod +metadata: + name: same-node-target-pod + namespace: spire + labels: + app: same-node-target-pod +spec: + nodeName: broker-api-k8s-worker + containers: + - name: target + image: broker-client:latest-local + imagePullPolicy: Never + command: ["/bin/sleep", "infinity"] + +--- +# Pod on a different node than the scoped broker pods. It only needs to +# exist for KubernetesObjectReference lookups. +apiVersion: v1 +kind: Pod +metadata: + name: other-node-target-pod + namespace: spire + labels: + app: other-node-target-pod +spec: + nodeName: broker-api-k8s-worker2 + containers: + - name: target + image: broker-client:latest-local + imagePullPolicy: Never + command: ["/bin/sleep", "infinity"] + +--- +# The "target" non-pod object — a Flux Kustomization CR. No controller is +# required; the resource just needs to exist in the API server so the broker +# can reference it. +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: target-kustomization + namespace: spire +spec: + interval: 1m + path: ./apps + prune: true + sourceRef: + kind: GitRepository + name: dummy + +--- +# The "broker" - authorized in the agent config to use PID references over UDS +# and Kubernetes object references over UDS or TCP. +apiVersion: v1 +kind: Pod +metadata: + name: broker + namespace: spire + labels: + app: broker +spec: + hostPID: true + nodeName: broker-api-k8s-worker + containers: + - name: broker + image: broker-client:latest-local + imagePullPolicy: Never + command: ["/bin/sleep", "infinity"] + volumeMounts: + - name: spire-agent-socket + mountPath: /run/spire/agent-sockets + readOnly: false + - name: spire-broker-socket + mountPath: /run/spire/broker-sockets + readOnly: false + volumes: + - name: spire-agent-socket + hostPath: + path: /run/spire/agent-sockets + type: Directory + - name: spire-broker-socket + hostPath: + path: /run/spire/broker-sockets + type: Directory + +--- +# Broker whose k8s attestor pod reference scope is `cluster`. +apiVersion: v1 +kind: Pod +metadata: + name: cluster-broker + namespace: spire + labels: + app: cluster-broker +spec: + hostPID: true + nodeName: broker-api-k8s-worker + containers: + - name: cluster-broker + image: broker-client:latest-local + imagePullPolicy: Never + command: ["/bin/sleep", "infinity"] + volumeMounts: + - name: spire-agent-socket + mountPath: /run/spire/agent-sockets + readOnly: false + - name: spire-broker-socket + mountPath: /run/spire/broker-sockets + readOnly: false + volumes: + - name: spire-agent-socket + hostPath: + path: /run/spire/agent-sockets + type: Directory + - name: spire-broker-socket + hostPath: + path: /run/spire/broker-sockets + type: Directory + +--- +# Broker whose k8s attestor pod reference scope is `agent_node`. +apiVersion: v1 +kind: Pod +metadata: + name: agent-node-broker + namespace: spire + labels: + app: agent-node-broker +spec: + hostPID: true + nodeName: broker-api-k8s-worker + containers: + - name: agent-node-broker + image: broker-client:latest-local + imagePullPolicy: Never + command: ["/bin/sleep", "infinity"] + volumeMounts: + - name: spire-agent-socket + mountPath: /run/spire/agent-sockets + readOnly: false + - name: spire-broker-socket + mountPath: /run/spire/broker-sockets + readOnly: false + volumes: + - name: spire-agent-socket + hostPath: + path: /run/spire/agent-sockets + type: Directory + - name: spire-broker-socket + hostPath: + path: /run/spire/broker-sockets + type: Directory + +--- +# Broker allowed to use KubernetesObjectReference over the local UDS only. +# Its object references must be rejected over the TCP broker listener because +# allow_over_tcp is not set for that reference type. +apiVersion: v1 +kind: Pod +metadata: + name: local-only-object-broker + namespace: spire + labels: + app: local-only-object-broker +spec: + hostPID: true + nodeName: broker-api-k8s-worker + containers: + - name: local-only-object-broker + image: broker-client:latest-local + imagePullPolicy: Never + command: ["/bin/sleep", "infinity"] + volumeMounts: + - name: spire-agent-socket + mountPath: /run/spire/agent-sockets + readOnly: false + - name: spire-broker-socket + mountPath: /run/spire/broker-sockets + readOnly: false + volumes: + - name: spire-agent-socket + hostPath: + path: /run/spire/agent-sockets + type: Directory + - name: spire-broker-socket + hostPath: + path: /run/spire/broker-sockets + type: Directory + +--- +# The "restricted broker" — authorized in the agent config but limited to +# WorkloadPIDReference only. KubernetesObjectReference attempts should be +# rejected with PermissionDenied at the gRPC layer. +apiVersion: v1 +kind: Pod +metadata: + name: restricted-broker + namespace: spire + labels: + app: restricted-broker +spec: + hostPID: true + nodeName: broker-api-k8s-worker + containers: + - name: restricted-broker + image: broker-client:latest-local + imagePullPolicy: Never + command: ["/bin/sleep", "infinity"] + volumeMounts: + - name: spire-agent-socket + mountPath: /run/spire/agent-sockets + readOnly: false + - name: spire-broker-socket + mountPath: /run/spire/broker-sockets + readOnly: false + volumes: + - name: spire-agent-socket + hostPath: + path: /run/spire/agent-sockets + type: Directory + - name: spire-broker-socket + hostPath: + path: /run/spire/broker-sockets + type: Directory + +--- +# The "RBAC denied broker" is authorized by the Broker API config to use +# KubernetesObjectReference, but its broker SPIFFE ID is not allowed to use +# `impersonate-via-spire` by the Kubernetes authorizer. +apiVersion: v1 +kind: Pod +metadata: + name: rbac-denied-broker + namespace: spire + labels: + app: rbac-denied-broker +spec: + hostPID: true + nodeName: broker-api-k8s-worker + containers: + - name: rbac-denied-broker + image: broker-client:latest-local + imagePullPolicy: Never + command: ["/bin/sleep", "infinity"] + volumeMounts: + - name: spire-agent-socket + mountPath: /run/spire/agent-sockets + readOnly: false + - name: spire-broker-socket + mountPath: /run/spire/broker-sockets + readOnly: false + volumes: + - name: spire-agent-socket + hostPath: + path: /run/spire/agent-sockets + type: Directory + - name: spire-broker-socket + hostPath: + path: /run/spire/broker-sockets + type: Directory + +--- +# The "unauthorized workload" — gets its own SVID via the Workload API just +# fine, but isn't in the agent's broker allowlist. Attempts to dial the +# broker endpoint should fail at the mTLS layer. +apiVersion: v1 +kind: Pod +metadata: + name: unauthorized-workload + namespace: spire + labels: + app: unauthorized-workload +spec: + hostPID: true + nodeName: broker-api-k8s-worker + containers: + - name: unauthorized-workload + image: broker-client:latest-local + imagePullPolicy: Never + command: ["/bin/sleep", "infinity"] + volumeMounts: + - name: spire-agent-socket + mountPath: /run/spire/agent-sockets + readOnly: false + - name: spire-broker-socket + mountPath: /run/spire/broker-sockets + readOnly: false + volumes: + - name: spire-agent-socket + hostPath: + path: /run/spire/agent-sockets + type: Directory + - name: spire-broker-socket + hostPath: + path: /run/spire/broker-sockets + type: Directory diff --git a/test/integration/suites/broker-api-k8s/init-kubectl b/test/integration/suites/broker-api-k8s/init-kubectl new file mode 100755 index 0000000000..2b7d453a06 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/init-kubectl @@ -0,0 +1,8 @@ +#!/bin/bash + +KUBECONFIG="${RUNDIR}/kubeconfig" +if [ ! -f "${RUNDIR}/kubeconfig" ]; then + ./bin/kind get kubeconfig --name=broker-api-k8s > "${RUNDIR}/kubeconfig" +fi +export KUBECONFIG + diff --git a/test/integration/suites/broker-api-k8s/teardown b/test/integration/suites/broker-api-k8s/teardown new file mode 100755 index 0000000000..a14acc0b23 --- /dev/null +++ b/test/integration/suites/broker-api-k8s/teardown @@ -0,0 +1,15 @@ +#!/bin/bash + +source init-kubectl + +if [ -z "$SUCCESS" ]; then + ./bin/kubectl -nspire logs deployment/spire-server --all-containers || true + ./bin/kubectl -nspire logs daemonset/spire-agent --all-containers || true + for pod in broker cluster-broker agent-node-broker restricted-broker rbac-denied-broker unauthorized-workload target-pod same-node-target-pod other-node-target-pod; do + ./bin/kubectl -nspire logs "pod/${pod}" --all-containers || true + ./bin/kubectl -nspire describe "pod/${pod}" || true + done +fi + +export KUBECONFIG= +./bin/kind delete cluster --name broker-api-k8s diff --git a/test/integration/suites/datastore-mysql-replication/00-setup b/test/integration/suites/datastore-mysql-replication/00-setup index 201d97cf09..56cb1ce360 100755 --- a/test/integration/suites/datastore-mysql-replication/00-setup +++ b/test/integration/suites/datastore-mysql-replication/00-setup @@ -16,4 +16,4 @@ log-debug "copying over test data..." cp -r "${PKGDIR}"/testdata . log-debug "pulling mysql docker images..." -docker compose pull +retry docker compose pull diff --git a/test/integration/suites/datastore-mysql/00-setup b/test/integration/suites/datastore-mysql/00-setup index 125458bdbe..da2de96a45 100755 --- a/test/integration/suites/datastore-mysql/00-setup +++ b/test/integration/suites/datastore-mysql/00-setup @@ -13,4 +13,4 @@ log-debug "copying over test data..." cp -r "${PKGDIR}"/testdata . log-debug "pulling mysql docker images..." -docker compose pull +retry docker compose pull diff --git a/test/integration/suites/datastore-postgres-replication/00-setup b/test/integration/suites/datastore-postgres-replication/00-setup index 2519740cb8..89226fd42c 100755 --- a/test/integration/suites/datastore-postgres-replication/00-setup +++ b/test/integration/suites/datastore-postgres-replication/00-setup @@ -13,4 +13,4 @@ log-debug "copying over test data..." cp -r "${PKGDIR}"/testdata . log-debug "pulling postgres docker images..." -docker compose pull +retry docker compose pull diff --git a/test/integration/suites/datastore-postgres/00-setup b/test/integration/suites/datastore-postgres/00-setup index 8ec90c4527..c5e7b14243 100755 --- a/test/integration/suites/datastore-postgres/00-setup +++ b/test/integration/suites/datastore-postgres/00-setup @@ -13,4 +13,4 @@ log-debug "copying over test data..." cp -r "${PKGDIR}"/testdata . log-debug "pulling postgres docker images..." -docker compose pull +retry docker compose pull diff --git a/test/integration/suites/fetch-jwt-svids/08-test-disabled-jwt b/test/integration/suites/fetch-jwt-svids/08-test-disabled-jwt index c69353b0bb..69e9db2e6f 100755 --- a/test/integration/suites/fetch-jwt-svids/08-test-disabled-jwt +++ b/test/integration/suites/fetch-jwt-svids/08-test-disabled-jwt @@ -4,23 +4,34 @@ log-info "testing disabled JWT functionality..." # Replace with disable_jwt_svids = true configuration sed -i.bak "s#disable_jwt_svids = false#disable_jwt_svids = true#g" conf/server/server.conf -docker compose restart spire-server -check-server-started spire-server + +docker-spire-server-restart spire-server test-jwt-disabled-command() { local test_name="$1" shift 1 local command=("$@") - - if ! ERROR_OUTPUT=$("${command[@]}" 2>&1); then + + # Retry to ride out the brief window right after the restart where the + # server (or the agent's connection to it) is not ready yet and the command + # fails with a transient transport error instead of the expected JWT + # disabled error. The agent JWT fetch reaches the server over its TCP API, + # so it is the check most prone to racing the restart. + local MAXCHECKS=15 + local CHECKINTERVAL=1 + for ((i=1;i<=MAXCHECKS;i++)); do + if ERROR_OUTPUT=$("${command[@]}" 2>&1); then + fail-now "$test_name should have failed when JWT is disabled" + fi if echo "$ERROR_OUTPUT" | grep -q "JWT functionality is disabled"; then log-info "✓ $test_name correctly failed with JWT disabled error" - else - fail-now "$test_name expected JWT disabled error, but got: $ERROR_OUTPUT" + return 0 fi - else - fail-now "$test_name should have failed when JWT is disabled" - fi + log-info "$test_name not ready yet (attempt $i of $MAXCHECKS): $ERROR_OUTPUT" + sleep "${CHECKINTERVAL}" + done + + fail-now "$test_name expected JWT disabled error, but got: $ERROR_OUTPUT" } test-jwt-disabled-command "JWT authority prepare" \ diff --git a/test/integration/suites/force-rotation-self-signed/00-setup b/test/integration/suites/force-rotation-self-signed/00-setup index 607b5446e3..641eb47812 100755 --- a/test/integration/suites/force-rotation-self-signed/00-setup +++ b/test/integration/suites/force-rotation-self-signed/00-setup @@ -24,4 +24,4 @@ mkdir -p -m 777 shared/intermediateBSocket # leafB certificates "${ROOTDIR}/setup/x509pop/setup.sh" leafB/server leafB/agent -docker build --target nested-agent-alpine -t nested-agent-alpine . +retry docker build --target nested-agent-alpine -t nested-agent-alpine . diff --git a/test/integration/suites/ghostunnel-federation/00-setup b/test/integration/suites/ghostunnel-federation/00-setup index 6d38a34aa5..362b407771 100755 --- a/test/integration/suites/ghostunnel-federation/00-setup +++ b/test/integration/suites/ghostunnel-federation/00-setup @@ -5,4 +5,4 @@ set -e "${ROOTDIR}/setup/x509pop/setup.sh" conf/downstream/server conf/downstream/agent "${ROOTDIR}/setup/x509pop/setup.sh" conf/upstream/server conf/upstream/agent -docker build --target socat-ghostunnel-agent-mashup -t socat-ghostunnel-agent-mashup . +retry docker build --target socat-ghostunnel-agent-mashup -t socat-ghostunnel-agent-mashup . diff --git a/test/integration/suites/nested-rotation/00-setup b/test/integration/suites/nested-rotation/00-setup index b106b29447..11307d7b59 100755 --- a/test/integration/suites/nested-rotation/00-setup +++ b/test/integration/suites/nested-rotation/00-setup @@ -30,4 +30,4 @@ mkdir -p -m 777 shared/intermediateB/data # leafB certificates "${ROOTDIR}/setup/x509pop/setup.sh" leafB/server leafB/agent -docker build --target nested-agent-alpine -t nested-agent-alpine . +retry docker build --target nested-agent-alpine -t nested-agent-alpine . diff --git a/test/integration/suites/spire-server-cli/01-start-server b/test/integration/suites/spire-server-cli/01-start-server index 60bdd68029..c9ed2fbe40 100755 --- a/test/integration/suites/spire-server-cli/01-start-server +++ b/test/integration/suites/spire-server-cli/01-start-server @@ -1,5 +1,5 @@ #!/bin/bash -docker build --target spire-server-alpine -t spire-server-alpine . +retry docker build --target spire-server-alpine -t spire-server-alpine . docker-spire-server-up spire-server diff --git a/test/integration/suites/upstream-authority-cert-manager/00-setup-kind b/test/integration/suites/upstream-authority-cert-manager/00-setup-kind index dd9b06d897..8b209c4738 100755 --- a/test/integration/suites/upstream-authority-cert-manager/00-setup-kind +++ b/test/integration/suites/upstream-authority-cert-manager/00-setup-kind @@ -14,7 +14,7 @@ download-kind "${KIND_PATH}" download-kubectl "${KUBECTL_PATH}" # Start the kind cluster. -start-kind-cluster "${KIND_PATH}" cert-manager-test ./conf/kind-config.yaml +start-kind-cluster "${KIND_PATH}" cert-manager-test # Load the given images in the cluster. container_images=("spire-server:latest-local") diff --git a/test/integration/suites/upstream-authority-cert-manager/conf/kind-config.yaml b/test/integration/suites/upstream-authority-cert-manager/conf/kind-config.yaml deleted file mode 100644 index 96abe52eaa..0000000000 --- a/test/integration/suites/upstream-authority-cert-manager/conf/kind-config.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: Cluster -apiVersion: kind.x-k8s.io/v1alpha4 -nodes: -- role: control-plane - image: kindest/node:v1.21.1@sha256:69860bda5563ac81e3c0057d654b5253219618a22ec3a346306239bba8cfa1a6 diff --git a/test/spiretest/logs.go b/test/spiretest/logs.go index 187421c24e..b85e07c9bc 100644 --- a/test/spiretest/logs.go +++ b/test/spiretest/logs.go @@ -61,8 +61,13 @@ func normalizeData(data logrus.Fields) logrus.Fields { if len(data) == 0 { return nil } + // Build a new map rather than mutating the caller's. The entries come + // from a logrus hook that shares each entry's Data map with logrus + // itself, so a background goroutine may still be formatting (reading) + // the same map while we normalize it. Mutating in place would race. + out := make(logrus.Fields, len(data)) for key, field := range data { - data[key] = fmt.Sprint(field) + out[key] = fmt.Sprint(field) } - return data + return out } diff --git a/test/testkey/ec256.pem b/test/testkey/ec256.pem index 6c0e081e35..d33f24b0db 100644 --- a/test/testkey/ec256.pem +++ b/test/testkey/ec256.pem @@ -265,3 +265,33 @@ MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgBjw7qfjYzeilMWAp sPlZd61DnvhzSZT4lgygrSPpbaShRANCAATW0kwcGPLnYXCbT8u075pvaUmYFyqO 1j5585G8ALMu+O/cvFDlB2CoUEpaMK+u7gVFKv/bnH9498jQwsztZ+DH -----END PRIVATE KEY----- +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgQ+SrZpZ9rBwe+jNO +AyVuRuqTOxUJBDN3UJ5Yki1mtJehRANCAARt2xZ6gLan74OQGpM6sXWpmAn8kUGc +eH2SKwPwDBDR6xegu06x6pwudRgoeDg0I1Gtbd6xW1DtpcAAJwqpgzx5 +-----END PRIVATE KEY----- +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg7SqDprVQwrlvSe3P +FM758J5vAWQuKhCJn2rKZ7UYxcWhRANCAASrF/elGij0AQh26rB2sMMlSd97R+I9 +Cv7qoK8hoL8JvvC+X9GsqDtMkPD+Veo3MNeREbbl5PMqSfI/zTDH9i10 +-----END PRIVATE KEY----- +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgoksBrQO0Ft0liV1F +zsR7SRKfHjOmhkxsGX0rFEC7Ex+hRANCAARw0pGA/akDKqvXazL2neY90rvayMQ+ +nOuoipXYWo3AbqDlwbyXEADAdtI8+Kcj6iqkLyVmPc1b07Z8uypptXVQ +-----END PRIVATE KEY----- +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgeW6pir90QpOal3Lx +JvaVIIcY8FyOcnH57eDsKWK5EU6hRANCAAQSThKPynvTRisNCQ1qg1pDJqanplJy +hSYvYnumSU3qf5SssNpwlYkwHJDZt3gife1PtTc0Vct9KZACkjOFsrx9 +-----END PRIVATE KEY----- +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQguGTWfF5b1jvk5iS/ +1Wuk+MRBTrle0L24Sc/s1TaLi4ihRANCAASRVJHVaJizjfAnkOkn6Tdjr50rBHaN +959NysVvec3Wj/pO0uwiTOhTF3QBE6J9Ab9sx67g3W7CEGXevyC10X9H +-----END PRIVATE KEY----- +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgdLehmc8BIZMnI/2+ +LnHX19/fekgxxXJRXZCvBePfb0GhRANCAATGipjQZVZbPZA4qtorvTtdo0b0oPLv +/kOV4u5Ejezzaymy+03+rltgv9iLhiU9yYi4p+ot1Dp3COG/kCVdWFSP +-----END PRIVATE KEY-----