From 2fad55e9ea08cf15a5bc1da925c5597a0492fbb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20=C3=85sell?= Date: Wed, 14 Oct 2026 02:41:49 +0200 Subject: [PATCH 1/4] feat(provenance): ankra application provenance get|set|verify and ankra org provenance (ankra-q573dh.44) Drives the verified-provenance routes from cluster#4611: the per-application mode, the organisation default, the dry-run verify, and the signing keys (list, rotate, revoke). Every place that shows or turns on enforce says that versions published before the first attested publish cannot be deployed or rolled back to, and how to switch to warn. Turning enforce on and revoking a key ask first. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 18 ++ cmd/application_provenance.go | 269 ++++++++++++++++++++++ cmd/application_resources.go | 1 + cmd/e2e_test.go | 32 +++ cmd/org_provenance.go | 289 ++++++++++++++++++++++++ cmd/provenance_common.go | 122 ++++++++++ cmd/provenance_test.go | 407 ++++++++++++++++++++++++++++++++++ cmd/services.go | 9 + internal/client/provenance.go | 210 ++++++++++++++++++ 9 files changed, 1357 insertions(+) create mode 100644 cmd/application_provenance.go create mode 100644 cmd/org_provenance.go create mode 100644 cmd/provenance_common.go create mode 100644 cmd/provenance_test.go create mode 100644 internal/client/provenance.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0961713f..01419574 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ ### Added +- **`ankra application provenance` and `ankra org provenance` show and change + whether deploys verify signed provenance.** A pipeline's publish step signs + a statement about every image it publishes, and a deploy can refuse an image + that is not attested. `ankra application provenance get ` shows the + mode an application's deploys run under (`off`, `warn` or `enforce`) and + whether it is the application's own setting, the organisation default or + Ankra's default; `set --mode off|warn|enforce` changes it (`--clear` + makes the application follow the organisation again); `verify --image + ` is a dry-run that reports what a deploy of those images would do and + exits 1 when it would be refused. `ankra org provenance get|set` holds the + organisation default, and `keys`, `rotate` and `revoke ` manage the + organisation's signing keys (`keys --public-keys` prints the PEM for + `cosign verify-attestation`). Under `enforce`, a version published before + the application's first attested publish cannot be deployed or rolled back + to: every command that shows or turns on `enforce` says so, and how to + switch to `warn` and back. Turning `enforce` on asks first (`--yes` skips + it), and so does revoking a key. Changing a mode or a key needs the + `security.manage` permission (exit 7 without it). - **`ankra pipeline tests quarantine` keeps a known-flaky test from failing your runs while someone fixes it.** The quarantine is off until `ankra pipeline tests quarantine settings --enable` turns it on for the repository. diff --git a/cmd/application_provenance.go b/cmd/application_provenance.go new file mode 100644 index 00000000..619c5ca9 --- /dev/null +++ b/cmd/application_provenance.go @@ -0,0 +1,269 @@ +package cmd + +// `ankra application provenance get|set|verify` (ankra-q573dh.44): the CLI +// over GET/PUT /api/v1/org/applications/{id}/provenance-policy and +// POST .../provenance/verify, the per-application mode of verified provenance +// at deploy (ankra-q573dh.13) and its dry-run. + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "ankra/internal/client" +) + +const provenanceRequestTimeout = 60 * time.Second + +func newApplicationProvenanceCommand() *cobra.Command { + command := &cobra.Command{ + Use: "provenance", + Short: "Show or change whether an application's deploys verify signed provenance", + Long: `An application can refuse to deploy an image unless the image carries signed +provenance showing it passed this organisation's pipeline gate. A pipeline's +publish step signs a statement about every image it publishes; the deploy +verifies it before rolling the image. + +` + provenanceModeHelp + ` + +An application's own mode wins; without one it follows the organisation default +('ankra org provenance'), and without that Ankra's default applies. + +Third-party images (a public database image, a chart's sidecar) are outside the +application's registry, so they are listed as unchecked and never refused.`, + } + command.AddCommand( + newApplicationProvenanceGetCommand(), + newApplicationProvenanceSetCommand(), + newApplicationProvenanceVerifyCommand(), + ) + return command +} + +func newApplicationProvenanceGetCommand() *cobra.Command { + command := &cobra.Command{ + Use: "get ", + Short: "Show the application's effective provenance mode and where it comes from", + Long: `Show the mode deploys of the application run under, whether it is the +application's own setting, the organisation default or Ankra's default, and what +that mode means for rollbacks. + +To see what the next deploy of specific images would do, use +'ankra application provenance verify'.`, + Example: ` ankra application provenance get shop + ankra application provenance get 23298741-6a5a-401a-a681-66f31fbdebe1 -o json`, + Args: cobra.ExactArgs(1), + RunE: func(command *cobra.Command, arguments []string) error { + if _, formatError := structuredFormatFromFlags(command); formatError != nil { + return formatError + } + applicationID, resolveError := resolveApplicationArgument(command, arguments) + if resolveError != nil { + return resolveError + } + requestContext, cancel := context.WithTimeout(command.Context(), provenanceRequestTimeout) + defer cancel() + policy, getError := apiClient.GetApplicationProvenancePolicy(requestContext, applicationID) + if getError != nil { + return provenanceError(getError, "reading deploy provenance for the application", false) + } + if rendered, renderError := renderStructured(command, policy); rendered || renderError != nil { + return renderError + } + printApplicationProvenancePolicy(command.OutOrStdout(), arguments[0], policy) + return nil + }, + } + registerStructuredOutputFlags(command) + return command +} + +func newApplicationProvenanceSetCommand() *cobra.Command { + command := &cobra.Command{ + Use: "set --mode off|warn|enforce", + Short: "Set the application's provenance mode", + Long: `Set the mode deploys of the application run under, or clear the application's +own choice with --clear so it follows the organisation default again. Needs the +security.manage permission. + +` + provenanceModeHelp + ` + +Turn warn on first and read the findings: an application whose images are built +outside Ankra pipelines is unsigned, and enforce would refuse every deploy of +it. Set enforce once the images you would deploy, and the rollback targets you +might need, were published by a pipeline run that attests them. + +Under enforce, versions published before the first attested publish cannot be +deployed or rolled back to. Switching to warn lifts that; this command asks +before turning enforce on (--yes skips the question).`, + Example: ` ankra application provenance set shop --mode warn + ankra application provenance set shop --mode enforce --yes + ankra application provenance set shop --clear`, + Args: cobra.ExactArgs(1), + RunE: runApplicationProvenanceSet, + } + command.Flags().String("mode", "", "Provenance mode: off, warn or enforce") + command.Flags().Bool("clear", false, "Clear the application's own mode so it follows the organisation default") + command.Flags().Bool("yes", false, "Skip the confirmation asked before turning enforce on") + registerStructuredOutputFlags(command) + return command +} + +func runApplicationProvenanceSet(command *cobra.Command, arguments []string) error { + if _, formatError := structuredFormatFromFlags(command); formatError != nil { + return formatError + } + mode, _, modeError := parseProvenanceModeFlags(command, "to follow the organisation default") + if modeError != nil { + return modeError + } + applicationID, resolveError := resolveApplicationArgument(command, arguments) + if resolveError != nil { + return resolveError + } + if mode != nil && *mode == client.ProvenanceModeEnforce { + yes, _ := command.Flags().GetBool("yes") + prompt := fmt.Sprintf("Turn enforce on for %q? Deploys are then refused unless every image is attested, and "+ + "versions published before the first attested publish cannot be deployed or rolled back to "+ + "(switch to warn to lift that). [y/N]: ", arguments[0]) + if confirmError := confirmPrompt(command.InOrStdin(), command.OutOrStdout(), prompt, yes); confirmError != nil { + return confirmError + } + } + requestContext, cancel := context.WithTimeout(command.Context(), provenanceRequestTimeout) + defer cancel() + policy, setError := apiClient.SetApplicationProvenancePolicy(requestContext, applicationID, mode) + if setError != nil { + return provenanceError(setError, "changing deploy provenance for the application", true) + } + // A stored state that disagrees with the request is a change that did not + // take effect; report it and exit non-zero so a script does not read it as + // the mode having moved. + var disagreement error + if provenanceModeLabel(policy.ApplicationMode, "") != provenanceModeLabel(mode, "") { + disagreement = withExitCode(exitError, fmt.Errorf( + "the platform stored the application's provenance mode as %s, not %s as requested; run "+ + "'ankra application provenance get %s' to check it", + provenanceModeLabel(policy.ApplicationMode, "unset"), provenanceModeLabel(mode, "unset"), arguments[0])) + } + if rendered, renderError := renderStructured(command, policy); rendered || renderError != nil { + if renderError != nil { + return renderError + } + return disagreement + } + out := command.OutOrStdout() + _, _ = fmt.Fprintf(out, "Deploy provenance updated for %q.\n\n", arguments[0]) + printApplicationProvenancePolicy(out, arguments[0], policy) + return disagreement +} + +func printApplicationProvenancePolicy(out io.Writer, reference string, policy *client.ApplicationProvenancePolicy) { + _, _ = fmt.Fprintf(out, " Effective mode: %s (%s)\n", policy.Mode, provenanceSourceLabel(policy.Source)) + _, _ = fmt.Fprintf(out, " Application's own: %s\n", provenanceModeLabel(policy.ApplicationMode, "not set")) + _, _ = fmt.Fprintf(out, " Organisation: %s\n", provenanceModeLabel(policy.OrganisationMode, "no default set")) + if policy.Mode == client.ProvenanceModeEnforce { + provenanceEnforceCaveat(out, fmt.Sprintf("ankra application provenance set %s --mode warn", reference)) + } +} + +func newApplicationProvenanceVerifyCommand() *cobra.Command { + command := &cobra.Command{ + Use: "verify --image [--image ...]", + Short: "Dry-run: check images against the application's provenance mode", + Long: `Verify images the way a deploy would and report what a deploy under the +application's mode would do, without deploying anything or filing a finding. + +Each image is checked for a signed attestation from this organisation's +pipeline: that it exists, that its signature verifies against a key the +organisation trusts, and that it names this application, repository and digest. +Images outside the application's registry are listed as unchecked and are never +refused. An image that could not be checked (the registry or key store did not +answer) is reported as unknown, never as refused. + +The decision is one of: deploy, deploy_with_finding (warn, an image is not +verified), refuse (enforce, an image is not verified) or not_checked (off). +The command exits 1 when the decision is refuse, so it can gate a script.`, + Example: ` ankra application provenance verify shop --image registry.ankra.cloud/org-1234/shop:sha-1a2b3c4 + ankra application provenance verify shop --image --image -o json`, + Args: cobra.ExactArgs(1), + RunE: func(command *cobra.Command, arguments []string) error { + if _, formatError := structuredFormatFromFlags(command); formatError != nil { + return formatError + } + images, _ := command.Flags().GetStringArray("image") + cleaned := make([]string, 0, len(images)) + for _, image := range images { + if trimmed := strings.TrimSpace(image); trimmed != "" { + cleaned = append(cleaned, trimmed) + } + } + if len(cleaned) == 0 || len(cleaned) > 32 { + return withExitCode(exitUsage, errors.New("pass between 1 and 32 images with --image")) + } + applicationID, resolveError := resolveApplicationArgument(command, arguments) + if resolveError != nil { + return resolveError + } + requestContext, cancel := context.WithTimeout(command.Context(), provenanceRequestTimeout) + defer cancel() + verification, verifyError := apiClient.VerifyApplicationProvenance(requestContext, applicationID, cleaned) + if verifyError != nil { + return provenanceError(verifyError, "verifying images for the application", false) + } + refused := verification.Decision == "refuse" + var refusal error + if refused { + refusal = withExitCode(exitError, errors.New("a deploy of these images would be refused")) + } + if rendered, renderError := renderStructured(command, verification); rendered || renderError != nil { + if renderError != nil { + return renderError + } + return refusal + } + printProvenanceVerification(command.OutOrStdout(), arguments[0], verification) + return refusal + }, + } + command.Flags().StringArray("image", nil, "Image reference to verify (repeatable, 1 to 32)") + registerStructuredOutputFlags(command) + return command +} + +func provenanceDecisionSentence(decision string) string { + switch decision { + case "deploy": + return "a deploy would go ahead" + case "deploy_with_finding": + return "a deploy would go ahead and file a finding (warn)" + case "refuse": + return "a deploy would be REFUSED (enforce)" + case "not_checked": + return "a deploy would not check provenance (off)" + } + return decision +} + +func printProvenanceVerification(out io.Writer, reference string, verification *client.ProvenanceVerification) { + _, _ = fmt.Fprintf(out, "Mode: %s\nDecision: %s\n\n", verification.Mode, provenanceDecisionSentence(verification.Decision)) + writer := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(writer, "IMAGE\tSTATE\tREASON\tDETAIL") + for _, image := range verification.Verdict.Images { + _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", image.Image, image.State, image.Reason, image.Message) + } + _ = writer.Flush() + for _, unchecked := range verification.Verdict.UncheckedImages { + _, _ = fmt.Fprintf(out, " not checked (outside the application's registry): %s\n", unchecked) + } + if verification.Mode == client.ProvenanceModeEnforce && verification.Decision == "refuse" { + _, _ = fmt.Fprintln(out, "\nUnder enforce, an image published before the first attested publish cannot be deployed or rolled back to.") + _, _ = fmt.Fprintf(out, "To deploy it anyway, switch to warn, deploy, then set enforce again:\n ankra application provenance set %s --mode warn\n", reference) + } +} diff --git a/cmd/application_resources.go b/cmd/application_resources.go index fe0c04be..8ddde327 100644 --- a/cmd/application_resources.go +++ b/cmd/application_resources.go @@ -137,6 +137,7 @@ func registerApplicationResourceCommands(applicationCommand *cobra.Command) { applicationCommand.AddCommand(newApplicationPublishedAddonCommand()) applicationCommand.AddCommand(newApplicationEnvSecretsCommand()) applicationCommand.AddCommand(newApplicationAutoDeployCommand()) + applicationCommand.AddCommand(newApplicationProvenanceCommand()) applicationCommand.AddCommand(newApplicationBuildCommand()) applicationCommand.AddCommand(newApplicationSettingsCommand()) applicationCommand.AddCommand(newApplicationManifestAddonCommand()) diff --git a/cmd/e2e_test.go b/cmd/e2e_test.go index 72b86454..eede477b 100644 --- a/cmd/e2e_test.go +++ b/cmd/e2e_test.go @@ -1712,6 +1712,38 @@ func (m baseMock) SetControlPlaneHeal(context.Context, string, bool) (*client.Co return nil, errors.New("not implemented") } +func (m baseMock) GetOrganisationProvenancePolicy(context.Context) (*client.OrganisationProvenancePolicy, error) { + return nil, errors.New("not implemented") +} + +func (m baseMock) SetOrganisationProvenancePolicy(context.Context, *string) (*client.OrganisationProvenancePolicy, error) { + return nil, errors.New("not implemented") +} + +func (m baseMock) GetApplicationProvenancePolicy(context.Context, string) (*client.ApplicationProvenancePolicy, error) { + return nil, errors.New("not implemented") +} + +func (m baseMock) SetApplicationProvenancePolicy(context.Context, string, *string) (*client.ApplicationProvenancePolicy, error) { + return nil, errors.New("not implemented") +} + +func (m baseMock) VerifyApplicationProvenance(context.Context, string, []string) (*client.ProvenanceVerification, error) { + return nil, errors.New("not implemented") +} + +func (m baseMock) ListProvenanceKeys(context.Context) (*client.ProvenanceSigningKeys, error) { + return nil, errors.New("not implemented") +} + +func (m baseMock) RotateProvenanceKey(context.Context) (*client.ProvenanceSigningKey, error) { + return nil, errors.New("not implemented") +} + +func (m baseMock) RevokeProvenanceKey(context.Context, string, string) (*client.ProvenanceSigningKey, error) { + return nil, errors.New("not implemented") +} + func (m baseMock) CreateHetznerCluster(req client.CreateHetznerClusterRequest) (*client.CreateHetznerClusterResponse, error) { return nil, errors.New("not implemented") } diff --git a/cmd/org_provenance.go b/cmd/org_provenance.go new file mode 100644 index 00000000..6257bd41 --- /dev/null +++ b/cmd/org_provenance.go @@ -0,0 +1,289 @@ +package cmd + +// `ankra org provenance get|set|keys|rotate|revoke` (ankra-q573dh.44): the +// organisation's default provenance mode and its provenance signing keys, +// over /api/v1/org/provenance/{policy,keys,keys/rotate,keys/revoke}. + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "ankra/internal/client" +) + +var orgProvenanceCmd = &cobra.Command{ + Use: "provenance", + Short: "Show or change the organisation's deploy provenance default and signing keys", + Long: `Verified provenance at deploy: a pipeline's publish step signs a statement about +every image it publishes, and a deploy can refuse an image that is not attested. +This command holds the organisation default and the signing keys. + + ankra org provenance get the default mode + ankra org provenance set --mode warn change it + ankra org provenance keys the signing keys + ankra org provenance rotate start a new key generation + ankra org provenance revoke stop trusting a key + +An application follows this default unless it has its own mode +('ankra application provenance'). + +` + provenanceModeHelp, +} + +func newOrgProvenanceGetCommand() *cobra.Command { + command := &cobra.Command{ + Use: "get", + Short: "Show the organisation's default provenance mode", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, arguments []string) error { + if _, formatError := structuredFormatFromFlags(command); formatError != nil { + return formatError + } + requestContext, cancel := context.WithTimeout(command.Context(), provenanceRequestTimeout) + defer cancel() + policy, getError := apiClient.GetOrganisationProvenancePolicy(requestContext) + if getError != nil { + return provenanceError(getError, "reading the organisation's deploy provenance default", false) + } + if rendered, renderError := renderStructured(command, policy); rendered || renderError != nil { + return renderError + } + printOrganisationProvenancePolicy(command.OutOrStdout(), policy) + return nil + }, + } + registerStructuredOutputFlags(command) + return command +} + +func printOrganisationProvenancePolicy(out io.Writer, policy *client.OrganisationProvenancePolicy) { + _, _ = fmt.Fprintf(out, " Organisation default: %s\n", provenanceModeLabel(policy.Mode, + "not set (applications use Ankra's default: off for applications created before 2026-10-14, warn since)")) + if policy.Mode != nil && *policy.Mode == client.ProvenanceModeEnforce { + provenanceEnforceCaveat(out, "ankra org provenance set --mode warn") + } +} + +func newOrgProvenanceSetCommand() *cobra.Command { + command := &cobra.Command{ + Use: "set --mode off|warn|enforce", + Short: "Set the organisation's default provenance mode", + Long: `Set the mode every application without its own mode runs under, or remove the +default with --clear. Needs the security.manage permission. + +` + provenanceModeHelp + ` + +Start with warn. enforce as an organisation default refuses the deploys of every +application that follows it and whose images are not attested; under enforce, +versions published before an application's first attested publish cannot be +deployed or rolled back to. This command asks before turning enforce on (--yes +skips the question).`, + Example: ` ankra org provenance set --mode warn + ankra org provenance set --clear`, + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, arguments []string) error { + if _, formatError := structuredFormatFromFlags(command); formatError != nil { + return formatError + } + mode, _, modeError := parseProvenanceModeFlags(command, "to remove the default") + if modeError != nil { + return modeError + } + if mode != nil && *mode == client.ProvenanceModeEnforce { + yes, _ := command.Flags().GetBool("yes") + prompt := "Turn enforce on as the organisation default? Every application that follows it then refuses " + + "unattested images, including rollbacks to versions published before its first attested publish " + + "(switch to warn to lift that). [y/N]: " + if confirmError := confirmPrompt(command.InOrStdin(), command.OutOrStdout(), prompt, yes); confirmError != nil { + return confirmError + } + } + requestContext, cancel := context.WithTimeout(command.Context(), provenanceRequestTimeout) + defer cancel() + policy, setError := apiClient.SetOrganisationProvenancePolicy(requestContext, mode) + if setError != nil { + return provenanceError(setError, "changing the organisation's deploy provenance default", true) + } + var disagreement error + if provenanceModeLabel(policy.Mode, "") != provenanceModeLabel(mode, "") { + disagreement = withExitCode(exitError, fmt.Errorf( + "the platform stored the organisation default as %s, not %s as requested; run "+ + "'ankra org provenance get' to check it", + provenanceModeLabel(policy.Mode, "unset"), provenanceModeLabel(mode, "unset"))) + } + if rendered, renderError := renderStructured(command, policy); rendered || renderError != nil { + if renderError != nil { + return renderError + } + return disagreement + } + out := command.OutOrStdout() + _, _ = fmt.Fprintln(out, "Organisation deploy provenance default updated.") + printOrganisationProvenancePolicy(out, policy) + return disagreement + }, + } + command.Flags().String("mode", "", "Provenance mode: off, warn or enforce") + command.Flags().Bool("clear", false, "Remove the organisation default") + command.Flags().Bool("yes", false, "Skip the confirmation asked before turning enforce on") + registerStructuredOutputFlags(command) + return command +} + +func newOrgProvenanceKeysCommand() *cobra.Command { + command := &cobra.Command{ + Use: "keys", + Short: "List the organisation's provenance signing keys", + Long: `List the organisation's provenance signing keys, newest first. Only public keys +are shown. + + active signs new attestations and verifies + trusted an earlier generation: verifies, no longer signs + revoked nothing it signed verifies any more + +--public-keys prints the PEM of every key that is not revoked, ready to save as +a file for 'cosign verify-attestation --key'.`, + Example: ` ankra org provenance keys + ankra org provenance keys --public-keys > provenance.pub`, + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, arguments []string) error { + if _, formatError := structuredFormatFromFlags(command); formatError != nil { + return formatError + } + requestContext, cancel := context.WithTimeout(command.Context(), provenanceRequestTimeout) + defer cancel() + keys, listError := apiClient.ListProvenanceKeys(requestContext) + if listError != nil { + return provenanceError(listError, "listing the organisation's provenance keys", false) + } + if rendered, renderError := renderStructured(command, keys); rendered || renderError != nil { + return renderError + } + out := command.OutOrStdout() + if onlyPEM, _ := command.Flags().GetBool("public-keys"); onlyPEM { + for _, key := range keys.Keys { + if key.Status != "revoked" { + _, _ = fmt.Fprint(out, strings.TrimRight(key.PublicKeyPEM, "\n")+"\n") + } + } + return nil + } + if len(keys.Keys) == 0 { + _, _ = fmt.Fprintln(out, "No provenance key exists yet. The first publish through a pipeline creates one.") + return nil + } + writer := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(writer, "GENERATION\tKEY ID\tSTATUS\tREVOKED") + for _, key := range keys.Keys { + revoked := "-" + if key.RevokedAt != nil { + revoked = *key.RevokedAt + } + _, _ = fmt.Fprintf(writer, "%d\t%s\t%s\t%s\n", key.Generation, key.KeyID, key.Status, revoked) + } + _ = writer.Flush() + if keys.PredicateType != "" { + _, _ = fmt.Fprintf(out, "\nAttestation type: %s\n", keys.PredicateType) + } + return nil + }, + } + command.Flags().Bool("public-keys", false, "Print the PEM of every key that is not revoked instead of the table") + registerStructuredOutputFlags(command) + return command +} + +func newOrgProvenanceRotateCommand() *cobra.Command { + command := &cobra.Command{ + Use: "rotate", + Short: "Start a new provenance key generation", + Long: `Create the next key generation. The next publish signs with it. Earlier +generations stay trusted, so images already published keep verifying. At most +16 generations exist per organisation. Needs the security.manage permission.`, + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, arguments []string) error { + if _, formatError := structuredFormatFromFlags(command); formatError != nil { + return formatError + } + requestContext, cancel := context.WithTimeout(command.Context(), provenanceRequestTimeout) + defer cancel() + key, rotateError := apiClient.RotateProvenanceKey(requestContext) + if rotateError != nil { + return provenanceError(rotateError, "rotating the organisation's provenance key", true) + } + if rendered, renderError := renderStructured(command, key); rendered || renderError != nil { + return renderError + } + _, _ = fmt.Fprintf(command.OutOrStdout(), + "Rotated. Generation %d (%s) is now the signing key. Earlier generations still verify.\n", + key.Generation, key.KeyID) + return nil + }, + } + registerStructuredOutputFlags(command) + return command +} + +func newOrgProvenanceRevokeCommand() *cobra.Command { + command := &cobra.Command{ + Use: "revoke ", + Short: "Stop trusting a provenance key", + Long: `Revoke a key. Nothing it signed verifies any more: an application under enforce +refuses those images until they are published again, which includes rolling back +to them. Revoking the active key makes the next publish create a successor. +Needs the security.manage permission. Use 'ankra org provenance keys' for key ids.`, + Example: ` ankra org provenance revoke --reason "key exposed in a build log"`, + Args: cobra.ExactArgs(1), + RunE: func(command *cobra.Command, arguments []string) error { + if _, formatError := structuredFormatFromFlags(command); formatError != nil { + return formatError + } + keyID := strings.TrimSpace(arguments[0]) + if keyID == "" { + return withExitCode(exitUsage, errors.New("a key id is required")) + } + reason, _ := command.Flags().GetString("reason") + if len(reason) > 500 { + return withExitCode(exitUsage, errors.New("--reason is at most 500 characters")) + } + yes, _ := command.Flags().GetBool("yes") + prompt := fmt.Sprintf("Revoke key %s? Nothing it signed will verify any more, so applications under enforce "+ + "refuse those images until they are published again. [y/N]: ", keyID) + if confirmError := confirmPrompt(command.InOrStdin(), command.OutOrStdout(), prompt, yes); confirmError != nil { + return confirmError + } + requestContext, cancel := context.WithTimeout(command.Context(), provenanceRequestTimeout) + defer cancel() + key, revokeError := apiClient.RevokeProvenanceKey(requestContext, keyID, reason) + if revokeError != nil { + return provenanceError(revokeError, "revoking the provenance key", true) + } + if rendered, renderError := renderStructured(command, key); rendered || renderError != nil { + return renderError + } + _, _ = fmt.Fprintf(command.OutOrStdout(), "Revoked generation %d (%s).\n", key.Generation, key.KeyID) + return nil + }, + } + command.Flags().String("reason", "", "Why the key is revoked (recorded in the audit log, at most 500 characters)") + command.Flags().Bool("yes", false, "Skip the confirmation prompt") + registerStructuredOutputFlags(command) + return command +} + +func init() { + orgProvenanceCmd.AddCommand( + newOrgProvenanceGetCommand(), + newOrgProvenanceSetCommand(), + newOrgProvenanceKeysCommand(), + newOrgProvenanceRotateCommand(), + newOrgProvenanceRevokeCommand(), + ) + orgCmd.AddCommand(orgProvenanceCmd) +} diff --git a/cmd/provenance_common.go b/cmd/provenance_common.go new file mode 100644 index 00000000..66576ae8 --- /dev/null +++ b/cmd/provenance_common.go @@ -0,0 +1,122 @@ +package cmd + +// Shared pieces of the verified-provenance commands (ankra-q573dh.44): +// `ankra application provenance` and `ankra org provenance`. Both read and +// write a mode (off, warn or enforce) and both must say, wherever enforce is +// in play, what enforce costs: a version published before an application's +// first attested publish carries no attestation, so it cannot be deployed or +// rolled back to until the application is switched to warn. + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/spf13/cobra" + + "ankra/internal/client" +) + +// provenanceModes are the modes a write may name, in the order of strictness. +var provenanceModes = []string{client.ProvenanceModeOff, client.ProvenanceModeWarn, client.ProvenanceModeEnforce} + +// provenanceModeHelp is the one-line explanation of each mode, shared by every +// command's help so the three do not drift apart. +const provenanceModeHelp = `Modes: + off nothing is checked at deploy, exactly as before + warn every image in the application's registry is checked; the deploy + goes ahead whatever the result, and an image that is not verified + files a finding on the application's installation + enforce the deploy is refused unless every image in the application's + registry carries a valid attestation from this organisation's + pipeline, and each verified image is pinned to its digest` + +// provenanceEnforceCaveat is printed wherever the effective mode is enforce. +// subject is how the thing is named in the follow-up command, for example +// "ankra application provenance set --mode warn". +func provenanceEnforceCaveat(out io.Writer, switchCommand string) { + _, _ = fmt.Fprintln(out, "\nUnder enforce a deploy is refused unless every image carries a valid attestation. Versions") + _, _ = fmt.Fprintln(out, "published before the first attested publish are not attested, so they cannot be deployed or") + _, _ = fmt.Fprintln(out, "rolled back to. To roll back past that point, switch to warn, deploy, then set enforce again:") + _, _ = fmt.Fprintf(out, " %s\n", switchCommand) +} + +// parseProvenanceModeFlags reads --mode and --clear. A nil mode with +// clear==true clears the choice. Exactly one of the two is required, so a +// write is always explicit. +func parseProvenanceModeFlags(command *cobra.Command, clearMeaning string) (mode *string, isClear bool, err error) { + modeValue, _ := command.Flags().GetString("mode") + isClear, _ = command.Flags().GetBool("clear") + hasMode := command.Flags().Changed("mode") + switch { + case hasMode && isClear: + return nil, false, withExitCode(exitUsage, errors.New("--mode and --clear cannot be combined")) + case !hasMode && !isClear: + return nil, false, withExitCode(exitUsage, fmt.Errorf( + "--mode off|warn|enforce is required (or --clear %s)", clearMeaning)) + case isClear: + return nil, true, nil + } + normalised := strings.ToLower(strings.TrimSpace(modeValue)) + for _, candidate := range provenanceModes { + if normalised == candidate { + return &normalised, false, nil + } + } + return nil, false, withExitCode(exitUsage, fmt.Errorf( + "--mode %q is not valid: use off, warn or enforce", modeValue)) +} + +// provenanceModeLabel renders a possibly-unset mode. +func provenanceModeLabel(mode *string, unset string) string { + if mode == nil { + return unset + } + return *mode +} + +// provenanceSourceLabel says where an application's effective mode came from. +func provenanceSourceLabel(source string) string { + switch source { + case "application": + return "this application's own setting" + case "organisation": + return "the organisation default" + case "default": + return "Ankra's default" + } + return source +} + +// provenanceError maps a refusal onto what the user can act on: a 403 on a +// write is a missing security.manage (exit 7), a 404 with the platform's own +// sentence is an unknown application or key (exit 3), and the router's bare +// 404 is a platform that predates the feature (exit 1). +func provenanceError(apiError error, operation string, writing bool) error { + var denied *client.PermissionDeniedError + var unexpected *client.UnexpectedResponseError + hasStatus := errors.As(apiError, &unexpected) + if errors.As(apiError, &denied) || (hasStatus && unexpected.StatusCode == http.StatusForbidden) { + if writing { + return withExitCode(exitForbidden, fmt.Errorf( + "%s: you need the security.manage permission to change deploy provenance settings and keys. "+ + "Ask an organisation admin for a role that has it", operation)) + } + return withExitCode(exitForbidden, fmt.Errorf("%s: %w", operation, apiError)) + } + if hasStatus && unexpected.StatusCode == http.StatusNotFound { + switch unexpected.Detail { + case "", routeAbsentDetail: + return withExitCode(exitError, fmt.Errorf( + "%s: this Ankra platform does not offer verified provenance yet (the route answered 404)", operation)) + default: + return withExitCode(exitNotFound, fmt.Errorf("%s: %w", operation, apiError)) + } + } + if hasStatus && unexpected.StatusCode == http.StatusServiceUnavailable { + return fmt.Errorf("%s: the provenance keys could not be read right now, try again shortly: %w", operation, apiError) + } + return fmt.Errorf("%s: %w", operation, apiError) +} diff --git a/cmd/provenance_test.go b/cmd/provenance_test.go new file mode 100644 index 00000000..132825ad --- /dev/null +++ b/cmd/provenance_test.go @@ -0,0 +1,407 @@ +package cmd + +// The verified-provenance commands against a scripted platform: the real +// client talks to an httptest server, so the method, path and body each verb +// sends are pinned on the request itself. + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/spf13/cobra" + + "ankra/internal/client" +) + +const testProvenanceApplicationID = "23298741-6a5a-401a-a681-66f31fbdebe1" + +type provenanceRequest struct { + method string + path string + body map[string]any +} + +type provenanceServer struct { + mutex sync.Mutex + requests []provenanceRequest + orgMode *string + appMode *string + verifyBody string + keysBody string + status int + statusBody string +} + +func newProvenanceServer(t *testing.T) *provenanceServer { + t.Helper() + recorder := &provenanceServer{} + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + recorded := provenanceRequest{method: request.Method, path: request.URL.Path} + if raw, _ := io.ReadAll(request.Body); len(raw) > 0 { + if decodeError := json.Unmarshal(raw, &recorded.body); decodeError != nil { + t.Errorf("the request body is not a JSON object: %v: %s", decodeError, raw) + } + } + recorder.mutex.Lock() + defer recorder.mutex.Unlock() + recorder.requests = append(recorder.requests, recorded) + if recorder.status != 0 { + writer.WriteHeader(recorder.status) + _, _ = writer.Write([]byte(recorder.statusBody)) + return + } + pathValue := request.URL.Path + write := func(value any) { _ = json.NewEncoder(writer).Encode(value) } + switch { + case pathValue == "/api/v1/org/provenance/policy": + if request.Method == http.MethodPut { + recorder.orgMode = modeFromBody(t, recorded.body) + } + write(map[string]any{"mode": recorder.orgMode}) + case strings.HasSuffix(pathValue, "/provenance-policy"): + if request.Method == http.MethodPut { + recorder.appMode = modeFromBody(t, recorded.body) + } + effective, source := "off", "default" + switch { + case recorder.appMode != nil: + effective, source = *recorder.appMode, "application" + case recorder.orgMode != nil: + effective, source = *recorder.orgMode, "organisation" + } + write(map[string]any{"application_id": testProvenanceApplicationID, "mode": effective, "source": source, + "application_mode": recorder.appMode, "organisation_mode": recorder.orgMode}) + case strings.HasSuffix(pathValue, "/provenance/verify"): + _, _ = writer.Write([]byte(recorder.verifyBody)) + case pathValue == "/api/v1/org/provenance/keys": + _, _ = writer.Write([]byte(recorder.keysBody)) + case pathValue == "/api/v1/org/provenance/keys/rotate": + writer.WriteHeader(http.StatusCreated) + write(map[string]any{"key_id": "k2", "public_key_pem": "PEM2", "generation": 2, "status": "active", "revoked_at": nil}) + case pathValue == "/api/v1/org/provenance/keys/revoke": + write(map[string]any{"key_id": recorded.body["key_id"], "public_key_pem": "PEM1", "generation": 1, + "status": "revoked", "revoked_at": "2026-10-14T10:00:00Z"}) + default: + writer.WriteHeader(http.StatusNotFound) + _, _ = writer.Write([]byte(`{"detail":"Not Found"}`)) + } + })) + t.Cleanup(server.Close) + setMockClient(t, client.New("test-token", server.URL)) + return recorder +} + +func modeFromBody(t *testing.T, body map[string]any) *string { + t.Helper() + value, present := body["mode"] + if !present { + t.Errorf("the write did not name mode: %v", body) + return nil + } + if value == nil { + return nil + } + text := value.(string) + return &text +} + +func (recorder *provenanceServer) seen() []provenanceRequest { + recorder.mutex.Lock() + defer recorder.mutex.Unlock() + return append([]provenanceRequest{}, recorder.requests...) +} + +func runProvenance(t *testing.T, stdin string, arguments ...string) (string, string, error) { + t.Helper() + stdout, stderr := new(bytes.Buffer), new(bytes.Buffer) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + rootCmd.SetIn(strings.NewReader(stdin)) + rootCmd.SetArgs(arguments) + var commands []*cobra.Command + for _, group := range []*cobra.Command{orgProvenanceCmd, findSubcommand(t, applicationCmd(t), "provenance")} { + commands = append(commands, group) + commands = append(commands, group.Commands()...) + } + resetTreeFlags(t, commands...) + t.Cleanup(func() { resetTreeFlags(t, commands...) }) + runError := rootCmd.Execute() + return stdout.String(), stderr.String(), runError +} + +func applicationCmd(t *testing.T) *cobra.Command { + t.Helper() + return findSubcommand(t, rootCmd, "application") +} + +func TestApplicationProvenanceGetUnderEnforceSaysOlderVersionsCannotBeRolledBack(t *testing.T) { + recorder := newProvenanceServer(t) + enforce := "enforce" + recorder.appMode = &enforce + stdout, _, runError := runProvenance(t, "", "application", "provenance", "get", testProvenanceApplicationID) + if runError != nil { + t.Fatalf("get failed: %v", runError) + } + for _, want := range []string{"Effective mode: enforce (this application's own setting)", + "cannot be deployed or", "rolled back to", "ankra application provenance set " + testProvenanceApplicationID + " --mode warn"} { + if !strings.Contains(stdout, want) { + t.Errorf("get output is missing %q:\n%s", want, stdout) + } + } + seen := recorder.seen() + if len(seen) != 1 || seen[0].method != http.MethodGet || + seen[0].path != "/api/v1/org/applications/"+testProvenanceApplicationID+"/provenance-policy" { + t.Errorf("unexpected requests: %+v", seen) + } +} + +func TestApplicationProvenanceGetUnderWarnHasNoRollbackCaveat(t *testing.T) { + recorder := newProvenanceServer(t) + warn := "warn" + recorder.orgMode = &warn + stdout, _, runError := runProvenance(t, "", "application", "provenance", "get", testProvenanceApplicationID) + if runError != nil { + t.Fatalf("get failed: %v", runError) + } + if !strings.Contains(stdout, "warn (the organisation default)") || strings.Contains(stdout, "rolled back") { + t.Errorf("unexpected output:\n%s", stdout) + } +} + +func TestApplicationProvenanceGetJSONKeepsStdoutClean(t *testing.T) { + newProvenanceServer(t) + stdout, _, runError := runProvenance(t, "", "application", "provenance", "get", testProvenanceApplicationID, "-o", "json") + if runError != nil { + t.Fatalf("get failed: %v", runError) + } + var decoded map[string]any + if jsonError := json.Unmarshal([]byte(stdout), &decoded); jsonError != nil { + t.Fatalf("stdout is not JSON: %v\n%s", jsonError, stdout) + } + if decoded["mode"] != "off" { + t.Errorf("unexpected mode: %v", decoded) + } +} + +func TestApplicationProvenanceSetSendsTheMode(t *testing.T) { + recorder := newProvenanceServer(t) + stdout, _, runError := runProvenance(t, "", "application", "provenance", "set", testProvenanceApplicationID, "--mode", "warn") + if runError != nil { + t.Fatalf("set failed: %v", runError) + } + seen := recorder.seen() + if len(seen) != 1 || seen[0].method != http.MethodPut || seen[0].body["mode"] != "warn" { + t.Errorf("unexpected requests: %+v", seen) + } + if !strings.Contains(stdout, "warn (this application's own setting)") { + t.Errorf("unexpected output:\n%s", stdout) + } +} + +func TestApplicationProvenanceSetEnforceAsksFirst(t *testing.T) { + recorder := newProvenanceServer(t) + _, _, runError := runProvenance(t, "n\n", "application", "provenance", "set", testProvenanceApplicationID, "--mode", "enforce") + if exitCodeFor(runError) != exitCancelled { + t.Fatalf("declining must exit %d, got %v", exitCancelled, runError) + } + if len(recorder.seen()) != 0 { + t.Errorf("a declined enforce must send nothing: %+v", recorder.seen()) + } + stdout, _, runError := runProvenance(t, "y\n", "application", "provenance", "set", testProvenanceApplicationID, "--mode", "enforce") + if runError != nil { + t.Fatalf("confirmed enforce failed: %v", runError) + } + if !strings.Contains(stdout, "cannot be deployed or rolled back to") && !strings.Contains(stdout, "rolled back to") { + t.Errorf("the prompt must name the rollback cost:\n%s", stdout) + } + if recorder.seen()[0].body["mode"] != "enforce" { + t.Errorf("unexpected request: %+v", recorder.seen()) + } +} + +func TestApplicationProvenanceSetEnforceWithYesSkipsThePrompt(t *testing.T) { + recorder := newProvenanceServer(t) + if _, _, runError := runProvenance(t, "", "application", "provenance", "set", testProvenanceApplicationID, + "--mode", "enforce", "--yes"); runError != nil { + t.Fatalf("set failed: %v", runError) + } + if len(recorder.seen()) != 1 { + t.Errorf("unexpected requests: %+v", recorder.seen()) + } +} + +func TestApplicationProvenanceSetClearSendsNull(t *testing.T) { + recorder := newProvenanceServer(t) + warn := "warn" + recorder.appMode = &warn + if _, _, runError := runProvenance(t, "", "application", "provenance", "set", testProvenanceApplicationID, "--clear"); runError != nil { + t.Fatalf("clear failed: %v", runError) + } + seen := recorder.seen() + value, present := seen[0].body["mode"] + if len(seen) != 1 || !present || value != nil { + t.Errorf("--clear must send mode:null, got %+v", seen) + } +} + +func TestApplicationProvenanceSetRequiresAnExplicitValidMode(t *testing.T) { + recorder := newProvenanceServer(t) + for _, arguments := range [][]string{ + {"application", "provenance", "set", testProvenanceApplicationID}, + {"application", "provenance", "set", testProvenanceApplicationID, "--mode", "strict"}, + {"application", "provenance", "set", testProvenanceApplicationID, "--mode", "warn", "--clear"}, + } { + _, _, runError := runProvenance(t, "", arguments...) + if exitCodeFor(runError) != exitUsage { + t.Errorf("%v must be a usage error, got %v", arguments, runError) + } + } + if len(recorder.seen()) != 0 { + t.Errorf("a usage error must send nothing: %+v", recorder.seen()) + } +} + +func TestApplicationProvenanceSetWithoutPermissionExitsForbidden(t *testing.T) { + recorder := newProvenanceServer(t) + recorder.status = http.StatusForbidden + recorder.statusBody = `{"detail":"You need the security.manage permission to change deploy provenance settings and keys."}` + _, _, runError := runProvenance(t, "", "application", "provenance", "set", testProvenanceApplicationID, "--mode", "warn") + if exitCodeFor(runError) != exitForbidden || !strings.Contains(runError.Error(), "security.manage") { + t.Errorf("want a forbidden error naming security.manage, got %v", runError) + } +} + +func TestApplicationProvenanceOldPlatformIsNamed(t *testing.T) { + recorder := newProvenanceServer(t) + recorder.status = http.StatusNotFound + recorder.statusBody = `{"detail":"Not Found"}` + _, _, runError := runProvenance(t, "", "application", "provenance", "get", testProvenanceApplicationID) + if runError == nil || !strings.Contains(runError.Error(), "does not offer verified provenance") { + t.Errorf("want the old-platform sentence, got %v", runError) + } +} + +const refusedVerification = `{"application_id":"` + testProvenanceApplicationID + `","mode":"enforce","decision":"refuse", +"verdict":{"state":"refused","images":[ + {"image":"registry.ankra.cloud/org-1/shop:sha-old","state":"refused","reason":"unsigned","message":"no attestation"}, + {"image":"registry.ankra.cloud/org-1/shop:sha-new","state":"verified","reason":"verified","message":"ok"}], + "unchecked_images":["docker.io/library/postgres:16"]}}` + +func TestApplicationProvenanceVerifyRefusedExitsNonZeroAndExplainsRollback(t *testing.T) { + recorder := newProvenanceServer(t) + recorder.verifyBody = refusedVerification + stdout, _, runError := runProvenance(t, "", "application", "provenance", "verify", testProvenanceApplicationID, + "--image", "registry.ankra.cloud/org-1/shop:sha-old", "--image", "registry.ankra.cloud/org-1/shop:sha-new") + if exitCodeFor(runError) != exitError { + t.Fatalf("a refused dry-run must exit 1, got %v", runError) + } + for _, want := range []string{"REFUSED", "sha-old", "unsigned", "not checked (outside the application's registry): docker.io/library/postgres:16", + "cannot be deployed or rolled back to", "--mode warn"} { + if !strings.Contains(stdout, want) { + t.Errorf("verify output is missing %q:\n%s", want, stdout) + } + } + images, _ := recorder.seen()[0].body["images"].([]any) + if len(images) != 2 { + t.Errorf("both images must be sent: %+v", recorder.seen()) + } +} + +func TestApplicationProvenanceVerifyNeedsAnImage(t *testing.T) { + recorder := newProvenanceServer(t) + _, _, runError := runProvenance(t, "", "application", "provenance", "verify", testProvenanceApplicationID) + if exitCodeFor(runError) != exitUsage || len(recorder.seen()) != 0 { + t.Errorf("want a usage error with nothing sent, got %v %+v", runError, recorder.seen()) + } +} + +func TestApplicationProvenanceVerifyAllowedExitsZero(t *testing.T) { + recorder := newProvenanceServer(t) + recorder.verifyBody = `{"application_id":"x","mode":"warn","decision":"deploy","verdict":{"state":"verified","images":[],"unchecked_images":[]}}` + stdout, _, runError := runProvenance(t, "", "application", "provenance", "verify", testProvenanceApplicationID, "--image", "a/b:c") + if runError != nil || !strings.Contains(stdout, "a deploy would go ahead") { + t.Errorf("unexpected result: %v\n%s", runError, stdout) + } +} + +func TestOrgProvenanceGetSetClear(t *testing.T) { + recorder := newProvenanceServer(t) + stdout, _, runError := runProvenance(t, "", "org", "provenance", "get") + if runError != nil || !strings.Contains(stdout, "not set") { + t.Fatalf("get: %v\n%s", runError, stdout) + } + stdout, _, runError = runProvenance(t, "", "org", "provenance", "set", "--mode", "warn") + if runError != nil || !strings.Contains(stdout, "warn") { + t.Fatalf("set: %v\n%s", runError, stdout) + } + if _, _, runError = runProvenance(t, "", "org", "provenance", "set", "--clear"); runError != nil { + t.Fatalf("clear: %v", runError) + } + seen := recorder.seen() + if seen[1].body["mode"] != "warn" || seen[2].body["mode"] != nil { + t.Errorf("unexpected writes: %+v", seen) + } +} + +func TestOrgProvenanceSetEnforceAsksFirstAndSaysWhy(t *testing.T) { + recorder := newProvenanceServer(t) + stdout, _, runError := runProvenance(t, "n\n", "org", "provenance", "set", "--mode", "enforce") + if exitCodeFor(runError) != exitCancelled || len(recorder.seen()) != 0 { + t.Fatalf("declining must send nothing and exit %d: %v %+v", exitCancelled, runError, recorder.seen()) + } + if !strings.Contains(stdout, "rollbacks to versions published before") || !strings.Contains(stdout, "warn") { + t.Errorf("the prompt must name the rollback cost and the way out:\n%s", stdout) + } +} + +const keysBody = `{"predicate_type":"https://ankra.io/attestations/pipeline-gate/v1","keys":[ +{"key_id":"k2","public_key_pem":"PEM2\n","generation":2,"status":"active","revoked_at":null}, +{"key_id":"k1","public_key_pem":"PEM1\n","generation":1,"status":"revoked","revoked_at":"2026-10-01T00:00:00Z"}]}` + +func TestOrgProvenanceKeysTableAndPublicKeys(t *testing.T) { + recorder := newProvenanceServer(t) + recorder.keysBody = keysBody + stdout, _, runError := runProvenance(t, "", "org", "provenance", "keys") + if runError != nil || !strings.Contains(stdout, "k2") || !strings.Contains(stdout, "revoked") || + !strings.Contains(stdout, "pipeline-gate/v1") { + t.Fatalf("keys: %v\n%s", runError, stdout) + } + stdout, _, runError = runProvenance(t, "", "org", "provenance", "keys", "--public-keys") + if runError != nil || stdout != "PEM2\n" { + t.Errorf("--public-keys must print only keys that are not revoked, got %q (%v)", stdout, runError) + } +} + +func TestOrgProvenanceRotate(t *testing.T) { + recorder := newProvenanceServer(t) + stdout, _, runError := runProvenance(t, "", "org", "provenance", "rotate") + if runError != nil || !strings.Contains(stdout, "Generation 2") { + t.Fatalf("rotate: %v\n%s", runError, stdout) + } + if seen := recorder.seen(); seen[0].method != http.MethodPost || seen[0].path != "/api/v1/org/provenance/keys/rotate" { + t.Errorf("unexpected request: %+v", seen) + } +} + +func TestOrgProvenanceRevokeConfirmsAndSendsReason(t *testing.T) { + recorder := newProvenanceServer(t) + _, _, runError := runProvenance(t, "n\n", "org", "provenance", "revoke", "k1") + if exitCodeFor(runError) != exitCancelled || len(recorder.seen()) != 0 { + t.Fatalf("declining must send nothing: %v %+v", runError, recorder.seen()) + } + stdout, _, runError := runProvenance(t, "", "org", "provenance", "revoke", "k1", "--yes", "--reason", "exposed") + if runError != nil || !strings.Contains(stdout, "Revoked generation 1") { + t.Fatalf("revoke: %v\n%s", runError, stdout) + } + body := recorder.seen()[0].body + if body["key_id"] != "k1" || body["reason"] != "exposed" { + t.Errorf("unexpected body: %+v", body) + } +} diff --git a/cmd/services.go b/cmd/services.go index e5d5b0fe..18e1fa77 100644 --- a/cmd/services.go +++ b/cmd/services.go @@ -535,6 +535,15 @@ type APIClient interface { GetControlPlaneHeal(ctx context.Context, clusterID string) (*client.ControlPlaneHeal, error) SetControlPlaneHeal(ctx context.Context, clusterID string, enabled bool) (*client.ControlPlaneHeal, error) + GetOrganisationProvenancePolicy(ctx context.Context) (*client.OrganisationProvenancePolicy, error) + SetOrganisationProvenancePolicy(ctx context.Context, mode *string) (*client.OrganisationProvenancePolicy, error) + GetApplicationProvenancePolicy(ctx context.Context, applicationID string) (*client.ApplicationProvenancePolicy, error) + SetApplicationProvenancePolicy(ctx context.Context, applicationID string, mode *string) (*client.ApplicationProvenancePolicy, error) + VerifyApplicationProvenance(ctx context.Context, applicationID string, images []string) (*client.ProvenanceVerification, error) + ListProvenanceKeys(ctx context.Context) (*client.ProvenanceSigningKeys, error) + RotateProvenanceKey(ctx context.Context) (*client.ProvenanceSigningKey, error) + RevokeProvenanceKey(ctx context.Context, keyID string, reason string) (*client.ProvenanceSigningKey, error) + CreateHetznerCluster(req client.CreateHetznerClusterRequest) (*client.CreateHetznerClusterResponse, error) DeprovisionHetznerCluster(clusterID string, options client.DeprovisionOptions) (*client.DeprovisionHetznerClusterResponse, error) StopHetznerCluster(clusterID string, options client.StopClusterOptions) (*client.ProviderStopClusterResponse, error) diff --git a/internal/client/provenance.go b/internal/client/provenance.go new file mode 100644 index 00000000..116a5911 --- /dev/null +++ b/internal/client/provenance.go @@ -0,0 +1,210 @@ +package client + +// Verified provenance at deploy (ankra-q573dh.13, surfaces ankra-q573dh.44): +// the organisation's default mode, an application's mode, the dry-run that +// verifies images against an application's policy, and the organisation's +// provenance signing keys. All are bearer routes under /api/v1/org. +// +// A mode is "off", "warn" or "enforce". On a write, a nil mode clears the +// choice (the organisation default then comes from Ankra's own default, and an +// application then follows the organisation); the body always carries the +// `mode` key, because the platform refuses a write that does not name it. + +import ( + "context" + "fmt" + "net/http" + neturl "net/url" +) + +// The three provenance modes the platform accepts. +const ( + ProvenanceModeOff = "off" + ProvenanceModeWarn = "warn" + ProvenanceModeEnforce = "enforce" +) + +// OrganisationProvenancePolicy is the organisation's default. Mode is nil +// when no default was chosen, which is not the same as "off". +type OrganisationProvenancePolicy struct { + Mode *string `json:"mode" yaml:"mode"` +} + +// ApplicationProvenancePolicy is one application's effective mode and where it +// came from: "application" (its own choice), "organisation" (the default) or +// "default" (Ankra's own). +type ApplicationProvenancePolicy struct { + ApplicationID string `json:"application_id" yaml:"application_id"` + Mode string `json:"mode" yaml:"mode"` + Source string `json:"source" yaml:"source"` + ApplicationMode *string `json:"application_mode" yaml:"application_mode"` + OrganisationMode *string `json:"organisation_mode" yaml:"organisation_mode"` +} + +// ProvenanceImageVerdict is the verdict for one image of a dry-run. State is +// "verified", "refused" or "unknown"; unknown means the check could not be +// made, never that the image was refused. +type ProvenanceImageVerdict struct { + Image string `json:"image" yaml:"image"` + State string `json:"state" yaml:"state"` + Reason string `json:"reason" yaml:"reason"` + Message string `json:"message" yaml:"message"` + Digest string `json:"digest,omitempty" yaml:"digest,omitempty"` + KeyID string `json:"key_id,omitempty" yaml:"key_id,omitempty"` + PipelineRunID string `json:"pipeline_run_id,omitempty" yaml:"pipeline_run_id,omitempty"` + CommitSHA string `json:"commit_sha,omitempty" yaml:"commit_sha,omitempty"` +} + +// ProvenanceVerdict is the overall dry-run verdict. UncheckedImages are the +// images outside the application's own registry project (third-party images), +// which are never judged and never refused. +type ProvenanceVerdict struct { + State string `json:"state" yaml:"state"` + Images []ProvenanceImageVerdict `json:"images" yaml:"images"` + UncheckedImages []string `json:"unchecked_images" yaml:"unchecked_images"` +} + +// ProvenanceVerification is the dry-run answer. Decision is what a deploy +// under the application's mode would do: deploy, deploy_with_finding, refuse +// or not_checked. +type ProvenanceVerification struct { + ApplicationID string `json:"application_id" yaml:"application_id"` + Mode string `json:"mode" yaml:"mode"` + Decision string `json:"decision" yaml:"decision"` + Verdict ProvenanceVerdict `json:"verdict" yaml:"verdict"` +} + +// ProvenanceSigningKey is one generation of the organisation's provenance key. +// Status is "active" (signs and verifies), "trusted" (verifies only) or +// "revoked". Only the public half is ever returned. +type ProvenanceSigningKey struct { + KeyID string `json:"key_id" yaml:"key_id"` + PublicKeyPEM string `json:"public_key_pem" yaml:"public_key_pem"` + Generation int `json:"generation" yaml:"generation"` + Status string `json:"status" yaml:"status"` + RevokedAt *string `json:"revoked_at" yaml:"revoked_at"` +} + +// ProvenanceSigningKeys is the key listing and the attestation predicate type. +type ProvenanceSigningKeys struct { + Keys []ProvenanceSigningKey `json:"keys" yaml:"keys"` + PredicateType string `json:"predicate_type" yaml:"predicate_type"` +} + +type provenanceModeUpdate struct { + Mode *string `json:"mode"` +} + +type provenanceVerifyRequest struct { + Images []string `json:"images"` +} + +type provenanceKeyRevocation struct { + KeyID string `json:"key_id"` + Reason string `json:"reason,omitempty"` +} + +func provenanceOrgURL(baseURL string, suffix string) string { + return baseURL + "/api/v1/org/provenance" + suffix +} + +func provenanceApplicationURL(baseURL string, applicationID string, suffix string) string { + return fmt.Sprintf("%s/api/v1/org/applications/%s%s", baseURL, neturl.PathEscape(applicationID), suffix) +} + +// GetOrganisationProvenancePolicy reads the organisation's default mode. +// GET /api/v1/org/provenance/policy +func (c *Client) GetOrganisationProvenancePolicy(ctx context.Context) (*OrganisationProvenancePolicy, error) { + var policy OrganisationProvenancePolicy + if requestError := c.sendJSONContext(ctx, http.MethodGet, + provenanceOrgURL(c.BaseURL, "/policy"), nil, &policy); requestError != nil { + return nil, requestError + } + return &policy, nil +} + +// SetOrganisationProvenancePolicy sets the organisation's default mode; nil +// clears it. Needs security.manage. +// PUT /api/v1/org/provenance/policy +func (c *Client) SetOrganisationProvenancePolicy(ctx context.Context, mode *string) (*OrganisationProvenancePolicy, error) { + var policy OrganisationProvenancePolicy + if requestError := c.sendJSONContext(ctx, http.MethodPut, + provenanceOrgURL(c.BaseURL, "/policy"), provenanceModeUpdate{Mode: mode}, &policy); requestError != nil { + return nil, requestError + } + return &policy, nil +} + +// GetApplicationProvenancePolicy reads an application's effective mode. +// GET /api/v1/org/applications/{application_id}/provenance-policy +func (c *Client) GetApplicationProvenancePolicy(ctx context.Context, applicationID string) (*ApplicationProvenancePolicy, error) { + var policy ApplicationProvenancePolicy + if requestError := c.sendJSONContext(ctx, http.MethodGet, + provenanceApplicationURL(c.BaseURL, applicationID, "/provenance-policy"), nil, &policy); requestError != nil { + return nil, requestError + } + return &policy, nil +} + +// SetApplicationProvenancePolicy sets an application's own mode; nil clears it +// so the application follows the organisation again. Needs security.manage. +// PUT /api/v1/org/applications/{application_id}/provenance-policy +func (c *Client) SetApplicationProvenancePolicy(ctx context.Context, applicationID string, mode *string) (*ApplicationProvenancePolicy, error) { + var policy ApplicationProvenancePolicy + if requestError := c.sendJSONContext(ctx, http.MethodPut, + provenanceApplicationURL(c.BaseURL, applicationID, "/provenance-policy"), + provenanceModeUpdate{Mode: mode}, &policy); requestError != nil { + return nil, requestError + } + return &policy, nil +} + +// VerifyApplicationProvenance is the dry-run: it verifies images (1 to 32) +// against the application's policy and reports what a deploy would do. It +// deploys nothing and files nothing. +// POST /api/v1/org/applications/{application_id}/provenance/verify +func (c *Client) VerifyApplicationProvenance(ctx context.Context, applicationID string, images []string) (*ProvenanceVerification, error) { + var verification ProvenanceVerification + if requestError := c.sendJSONContext(ctx, http.MethodPost, + provenanceApplicationURL(c.BaseURL, applicationID, "/provenance/verify"), + provenanceVerifyRequest{Images: images}, &verification); requestError != nil { + return nil, requestError + } + return &verification, nil +} + +// ListProvenanceKeys lists the organisation's provenance signing keys. +// GET /api/v1/org/provenance/keys +func (c *Client) ListProvenanceKeys(ctx context.Context) (*ProvenanceSigningKeys, error) { + var keys ProvenanceSigningKeys + if requestError := c.sendJSONContext(ctx, http.MethodGet, + provenanceOrgURL(c.BaseURL, "/keys"), nil, &keys); requestError != nil { + return nil, requestError + } + return &keys, nil +} + +// RotateProvenanceKey creates the next key generation; the next publish signs +// with it and earlier generations keep verifying. Needs security.manage. +// POST /api/v1/org/provenance/keys/rotate +func (c *Client) RotateProvenanceKey(ctx context.Context) (*ProvenanceSigningKey, error) { + var key ProvenanceSigningKey + if requestError := c.sendJSONContext(ctx, http.MethodPost, + provenanceOrgURL(c.BaseURL, "/keys/rotate"), nil, &key); requestError != nil { + return nil, requestError + } + return &key, nil +} + +// RevokeProvenanceKey revokes one key: nothing it signed verifies any more. +// Needs security.manage. +// POST /api/v1/org/provenance/keys/revoke +func (c *Client) RevokeProvenanceKey(ctx context.Context, keyID string, reason string) (*ProvenanceSigningKey, error) { + var key ProvenanceSigningKey + if requestError := c.sendJSONContext(ctx, http.MethodPost, + provenanceOrgURL(c.BaseURL, "/keys/revoke"), + provenanceKeyRevocation{KeyID: keyID, Reason: reason}, &key); requestError != nil { + return nil, requestError + } + return &key, nil +} From 98e61e085c0bbe54b291f7f883e38d580219c592 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20=C3=85sell?= Date: Wed, 14 Oct 2026 03:08:08 +0200 Subject: [PATCH 2/4] fix(provenance): do not hardcode the platform default date; 503 names the operation Co-Authored-By: Claude Sonnet 5.5 --- cmd/org_provenance.go | 2 +- cmd/provenance_common.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/org_provenance.go b/cmd/org_provenance.go index 6257bd41..8fdb8bbd 100644 --- a/cmd/org_provenance.go +++ b/cmd/org_provenance.go @@ -64,7 +64,7 @@ func newOrgProvenanceGetCommand() *cobra.Command { func printOrganisationProvenancePolicy(out io.Writer, policy *client.OrganisationProvenancePolicy) { _, _ = fmt.Fprintf(out, " Organisation default: %s\n", provenanceModeLabel(policy.Mode, - "not set (applications use Ankra's default: off for applications created before 2026-10-14, warn since)")) + "not set (applications use Ankra's own default)")) if policy.Mode != nil && *policy.Mode == client.ProvenanceModeEnforce { provenanceEnforceCaveat(out, "ankra org provenance set --mode warn") } diff --git a/cmd/provenance_common.go b/cmd/provenance_common.go index 66576ae8..7fd2a6ad 100644 --- a/cmd/provenance_common.go +++ b/cmd/provenance_common.go @@ -116,7 +116,7 @@ func provenanceError(apiError error, operation string, writing bool) error { } } if hasStatus && unexpected.StatusCode == http.StatusServiceUnavailable { - return fmt.Errorf("%s: the provenance keys could not be read right now, try again shortly: %w", operation, apiError) + return fmt.Errorf("%s: the platform could not answer right now, try again shortly: %w", operation, apiError) } return fmt.Errorf("%s: %w", operation, apiError) } From c1f6de02ba2ebfc29cac7a3066ffdca7d7b49e05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20=C3=85sell?= Date: Wed, 14 Oct 2026 03:34:26 +0200 Subject: [PATCH 3/4] fix(provenance): spell each route out so the cluster route check can resolve it Co-Authored-By: Claude Sonnet 5.5 --- internal/client/provenance.go | 44 +++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/internal/client/provenance.go b/internal/client/provenance.go index 116a5911..53626820 100644 --- a/internal/client/provenance.go +++ b/internal/client/provenance.go @@ -104,12 +104,32 @@ type provenanceKeyRevocation struct { Reason string `json:"reason,omitempty"` } -func provenanceOrgURL(baseURL string, suffix string) string { - return baseURL + "/api/v1/org/provenance" + suffix +// Each route is spelled out in full: the route check +// (cluster_routes_test.go) reads /api/v1 literals, and a shared prefix plus a +// suffix would hide the real path from it. + +func provenancePolicyURL(baseURL string) string { + return baseURL + "/api/v1/org/provenance/policy" +} + +func provenanceKeysURL(baseURL string) string { + return baseURL + "/api/v1/org/provenance/keys" +} + +func provenanceKeysRotateURL(baseURL string) string { + return baseURL + "/api/v1/org/provenance/keys/rotate" +} + +func provenanceKeysRevokeURL(baseURL string) string { + return baseURL + "/api/v1/org/provenance/keys/revoke" +} + +func applicationProvenancePolicyURL(baseURL string, applicationID string) string { + return fmt.Sprintf("%s/api/v1/org/applications/%s/provenance-policy", baseURL, neturl.PathEscape(applicationID)) } -func provenanceApplicationURL(baseURL string, applicationID string, suffix string) string { - return fmt.Sprintf("%s/api/v1/org/applications/%s%s", baseURL, neturl.PathEscape(applicationID), suffix) +func applicationProvenanceVerifyURL(baseURL string, applicationID string) string { + return fmt.Sprintf("%s/api/v1/org/applications/%s/provenance/verify", baseURL, neturl.PathEscape(applicationID)) } // GetOrganisationProvenancePolicy reads the organisation's default mode. @@ -117,7 +137,7 @@ func provenanceApplicationURL(baseURL string, applicationID string, suffix strin func (c *Client) GetOrganisationProvenancePolicy(ctx context.Context) (*OrganisationProvenancePolicy, error) { var policy OrganisationProvenancePolicy if requestError := c.sendJSONContext(ctx, http.MethodGet, - provenanceOrgURL(c.BaseURL, "/policy"), nil, &policy); requestError != nil { + provenancePolicyURL(c.BaseURL), nil, &policy); requestError != nil { return nil, requestError } return &policy, nil @@ -129,7 +149,7 @@ func (c *Client) GetOrganisationProvenancePolicy(ctx context.Context) (*Organisa func (c *Client) SetOrganisationProvenancePolicy(ctx context.Context, mode *string) (*OrganisationProvenancePolicy, error) { var policy OrganisationProvenancePolicy if requestError := c.sendJSONContext(ctx, http.MethodPut, - provenanceOrgURL(c.BaseURL, "/policy"), provenanceModeUpdate{Mode: mode}, &policy); requestError != nil { + provenancePolicyURL(c.BaseURL), provenanceModeUpdate{Mode: mode}, &policy); requestError != nil { return nil, requestError } return &policy, nil @@ -140,7 +160,7 @@ func (c *Client) SetOrganisationProvenancePolicy(ctx context.Context, mode *stri func (c *Client) GetApplicationProvenancePolicy(ctx context.Context, applicationID string) (*ApplicationProvenancePolicy, error) { var policy ApplicationProvenancePolicy if requestError := c.sendJSONContext(ctx, http.MethodGet, - provenanceApplicationURL(c.BaseURL, applicationID, "/provenance-policy"), nil, &policy); requestError != nil { + applicationProvenancePolicyURL(c.BaseURL, applicationID), nil, &policy); requestError != nil { return nil, requestError } return &policy, nil @@ -152,7 +172,7 @@ func (c *Client) GetApplicationProvenancePolicy(ctx context.Context, application func (c *Client) SetApplicationProvenancePolicy(ctx context.Context, applicationID string, mode *string) (*ApplicationProvenancePolicy, error) { var policy ApplicationProvenancePolicy if requestError := c.sendJSONContext(ctx, http.MethodPut, - provenanceApplicationURL(c.BaseURL, applicationID, "/provenance-policy"), + applicationProvenancePolicyURL(c.BaseURL, applicationID), provenanceModeUpdate{Mode: mode}, &policy); requestError != nil { return nil, requestError } @@ -166,7 +186,7 @@ func (c *Client) SetApplicationProvenancePolicy(ctx context.Context, application func (c *Client) VerifyApplicationProvenance(ctx context.Context, applicationID string, images []string) (*ProvenanceVerification, error) { var verification ProvenanceVerification if requestError := c.sendJSONContext(ctx, http.MethodPost, - provenanceApplicationURL(c.BaseURL, applicationID, "/provenance/verify"), + applicationProvenanceVerifyURL(c.BaseURL, applicationID), provenanceVerifyRequest{Images: images}, &verification); requestError != nil { return nil, requestError } @@ -178,7 +198,7 @@ func (c *Client) VerifyApplicationProvenance(ctx context.Context, applicationID func (c *Client) ListProvenanceKeys(ctx context.Context) (*ProvenanceSigningKeys, error) { var keys ProvenanceSigningKeys if requestError := c.sendJSONContext(ctx, http.MethodGet, - provenanceOrgURL(c.BaseURL, "/keys"), nil, &keys); requestError != nil { + provenanceKeysURL(c.BaseURL), nil, &keys); requestError != nil { return nil, requestError } return &keys, nil @@ -190,7 +210,7 @@ func (c *Client) ListProvenanceKeys(ctx context.Context) (*ProvenanceSigningKeys func (c *Client) RotateProvenanceKey(ctx context.Context) (*ProvenanceSigningKey, error) { var key ProvenanceSigningKey if requestError := c.sendJSONContext(ctx, http.MethodPost, - provenanceOrgURL(c.BaseURL, "/keys/rotate"), nil, &key); requestError != nil { + provenanceKeysRotateURL(c.BaseURL), nil, &key); requestError != nil { return nil, requestError } return &key, nil @@ -202,7 +222,7 @@ func (c *Client) RotateProvenanceKey(ctx context.Context) (*ProvenanceSigningKey func (c *Client) RevokeProvenanceKey(ctx context.Context, keyID string, reason string) (*ProvenanceSigningKey, error) { var key ProvenanceSigningKey if requestError := c.sendJSONContext(ctx, http.MethodPost, - provenanceOrgURL(c.BaseURL, "/keys/revoke"), + provenanceKeysRevokeURL(c.BaseURL), provenanceKeyRevocation{KeyID: keyID, Reason: reason}, &key); requestError != nil { return nil, requestError } From b66f23bb6843d47a7aa8f2965e8911eba4791652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20=C3=85sell?= Date: Wed, 14 Oct 2026 17:13:56 +0200 Subject: [PATCH 4/4] fix(provenance): say on stderr when --public-keys has no keys to print Co-Authored-By: Claude Sonnet 5.5 --- cmd/org_provenance.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/org_provenance.go b/cmd/org_provenance.go index 8fdb8bbd..6a4ebe80 100644 --- a/cmd/org_provenance.go +++ b/cmd/org_provenance.go @@ -167,6 +167,9 @@ a file for 'cosign verify-attestation --key'.`, } out := command.OutOrStdout() if onlyPEM, _ := command.Flags().GetBool("public-keys"); onlyPEM { + if len(keys.Keys) == 0 { + _, _ = fmt.Fprintln(command.ErrOrStderr(), "No provenance key exists yet. The first publish through a pipeline creates one.") + } for _, key := range keys.Keys { if key.Status != "revoked" { _, _ = fmt.Fprint(out, strings.TrimRight(key.PublicKeyPEM, "\n")+"\n")