diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec19a47..ad53a175 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 cluster agent ci set --build-apparmor-profile ` runs the rootless image builder under a Localhost AppArmor profile installed on the cluster's nodes.** On nodes whose container runtime confines the builder 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 6b3c0296..eb64b466 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..6a4ebe80 --- /dev/null +++ b/cmd/org_provenance.go @@ -0,0 +1,292 @@ +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 own default)")) + 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 { + 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") + } + } + 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..7fd2a6ad --- /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 platform could not answer 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..53626820 --- /dev/null +++ b/internal/client/provenance.go @@ -0,0 +1,230 @@ +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"` +} + +// 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 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. +// 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, + provenancePolicyURL(c.BaseURL), 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, + provenancePolicyURL(c.BaseURL), 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, + applicationProvenancePolicyURL(c.BaseURL, applicationID), 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, + applicationProvenancePolicyURL(c.BaseURL, applicationID), + 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, + applicationProvenanceVerifyURL(c.BaseURL, applicationID), + 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, + provenanceKeysURL(c.BaseURL), 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, + provenanceKeysRotateURL(c.BaseURL), 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, + provenanceKeysRevokeURL(c.BaseURL), + provenanceKeyRevocation{KeyID: keyID, Reason: reason}, &key); requestError != nil { + return nil, requestError + } + return &key, nil +}