diff --git a/CHANGELOG.md b/CHANGELOG.md index 6796bd83e..378eb84a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,50 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### [x.y.z] - unreleased +### Added + +- Add `--token` flag to `step-ca export` command to export provisioners and + admins from linked CAs before migrating to standalone mode. +- Add `step-ca import` command to import provisioners and admins from an export + file into a standalone CA's admin database. This enables migration from Linked + CA to standalone mode, or migration between standalone CAs. Features include: + - Automatic ID remapping for provisioners and admins + - Duplicate detection (skips existing provisioners by name, admins by subject) + - `--dry-run` flag to preview changes without modifying the database + +### Deprecated + +- Linked CA functionality is deprecated in open-source step-ca and will be + removed in a future version. Existing Linked CAs will continue to work but + will show a deprecation warning. Users requiring Linked CA features should + migrate to Step CA Pro. See https://smallstep.com/product/step-ca-pro/ + +#### Migrating from Linked CA to Standalone + +To migrate an existing linked CA to standalone mode: + +1. Export your current configuration including cloud-stored provisioners: + ``` + step-ca export $(step path)/config/ca.json --token $STEP_CA_TOKEN > export.json + ``` + +2. Stop the CA + +3. Update your `ca.json`: + - Remove the `authority.linkedca` section + - Ensure `authority.enableAdmin: true` + - Ensure `db` is configured + +4. Import the provisioners and admins: + ``` + step-ca import $(step path)/config/ca.json export.json + ``` + +5. Start the CA without the `--token` flag: + ``` + step-ca $(step path)/config/ca.json + ``` + ### Changed - Upgrade HSM-enabled Docker images from Debian Bookworm (12) to Debian Trixie diff --git a/authority/authority.go b/authority/authority.go index ea1bacd62..13c83d77a 100644 --- a/authority/authority.go +++ b/authority/authority.go @@ -346,6 +346,9 @@ func (a *Authority) init() error { // Automatically enable admin for all linked cas. if a.linkedCAToken != "" { + log.Println("DEPRECATION WARNING: Linked CA functionality in open-source step-ca " + + "is deprecated and will be removed in a future version. Please migrate to " + + "Step CA Pro. See https://smallstep.com/product/step-ca-pro/") a.config.AuthorityConfig.EnableAdmin = true } diff --git a/commands/app.go b/commands/app.go index 022f43dcb..75b88ef6d 100644 --- a/commands/app.go +++ b/commands/app.go @@ -112,6 +112,12 @@ func appAction(ctx *cli.Context) error { token := ctx.String("token") quiet := ctx.Bool("quiet") + if token != "" { + fmt.Fprintln(os.Stderr, "DEPRECATION WARNING: Linked CA (--token) in open-source "+ + "step-ca is deprecated and will be removed in a future version. "+ + "Please unlink your CA or migrate to Step CA Pro. https://smallstep.com/product/step-ca-pro/") + } + if ctx.NArg() > 1 { return errs.TooManyArguments(ctx) } diff --git a/commands/export.go b/commands/export.go index b09c290b6..313d122b9 100644 --- a/commands/export.go +++ b/commands/export.go @@ -29,6 +29,9 @@ func init() { Note that neither the PKI password nor the certificate issuer password will be included in the export file. +For linked CAs, use the **--token** flag to authenticate with the linked CA +service and export provisioners and admins stored in the cloud. + ## POSITIONAL ARGUMENTS @@ -39,6 +42,11 @@ included in the export file. Export the current configuration: ''' $ step-ca export $(step path)/config/ca.json +''' + +Export the configuration of a linked CA: +''' +$ step-ca export $(step path)/config/ca.json --token $STEP_CA_TOKEN '''`, Flags: []cli.Flag{ cli.StringFlag{ @@ -51,6 +59,11 @@ intermediate private key.`, Usage: `path to the containing the password to decrypt the certificate issuer private key used in the RA mode.`, }, + cli.StringFlag{ + Name: "token", + Usage: "token used to enable the linked CA.", + EnvVar: "STEP_CA_TOKEN", + }, }, }) } @@ -63,6 +76,7 @@ func exportAction(ctx *cli.Context) error { configFile := ctx.Args().Get(0) passwordFile := ctx.String("password-file") issuerPasswordFile := ctx.String("issuer-password-file") + token := ctx.String("token") cfg, err := config.LoadConfiguration(configFile) if err != nil { @@ -89,7 +103,7 @@ func exportAction(ctx *cli.Context) error { } } - auth, err := authority.New(cfg) + auth, err := authority.New(cfg, authority.WithLinkedCAToken(token)) if err != nil { return err } diff --git a/commands/import.go b/commands/import.go new file mode 100644 index 000000000..3883bec8d --- /dev/null +++ b/commands/import.go @@ -0,0 +1,243 @@ +package commands + +import ( + "context" + "fmt" + "os" + + "github.com/pkg/errors" + "github.com/urfave/cli" + "google.golang.org/protobuf/encoding/protojson" + + "github.com/smallstep/cli-utils/command" + "github.com/smallstep/cli-utils/errs" + "github.com/smallstep/linkedca" + + "github.com/smallstep/certificates/authority/admin" + adminDBNosql "github.com/smallstep/certificates/authority/admin/db/nosql" + "github.com/smallstep/certificates/authority/config" + "github.com/smallstep/certificates/db" +) + +func init() { + command.Register(cli.Command{ + Name: "import", + Usage: "import provisioners and admins from an export file", + UsageText: "**step-ca import** [**--dry-run**]", + Action: importAction, + Description: `**step-ca import** imports provisioners and admins from an export file +into the CA's admin database. + +This command is used to migrate from a Linked CA to a standalone CA, or to +migrate provisioners and admins between standalone CAs. + +The CA must be stopped before running this command. + +## POSITIONAL ARGUMENTS + + +: The ca.json configuration file. Must have 'authority.enableAdmin: true' + and a database configured. + + +: The export file created by 'step-ca export'. + +## EXAMPLES + +Import provisioners and admins from an export file: +''' +$ step-ca import $(step path)/config/ca.json export.json +''' + +Preview the import without making changes: +''' +$ step-ca import $(step path)/config/ca.json export.json --dry-run +'''`, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "dry-run", + Usage: "preview the import without making changes", + }, + }, + }) +} + +func importAction(ctx *cli.Context) error { + if err := errs.NumberOfArguments(ctx, 2); err != nil { + return err + } + + configFile := ctx.Args().Get(0) + exportFile := ctx.Args().Get(1) + dryRun := ctx.Bool("dry-run") + + // Load and validate configuration + cfg, err := config.LoadConfiguration(configFile) + if err != nil { + return err + } + if err := cfg.Validate(); err != nil { + return err + } + + // Check that enableAdmin is true + if cfg.AuthorityConfig == nil || !cfg.AuthorityConfig.EnableAdmin { + return errors.New("authority.enableAdmin must be true to use the import command") + } + + // Check that a database is configured + if cfg.DB == nil { + return errors.New("a database must be configured to use the import command") + } + + // Read and parse export file + exportData, err := os.ReadFile(exportFile) + if err != nil { + return errors.Wrapf(err, "error reading export file %s", exportFile) + } + + var export linkedca.ConfigurationResponse + if err := protojson.Unmarshal(exportData, &export); err != nil { + return errors.Wrap(err, "error parsing export file") + } + + if dryRun { + fmt.Println("=== DRY RUN - No changes will be made ===") + fmt.Println() + } + + // Open database + authDB, err := db.New(cfg.DB) + if err != nil { + return errors.Wrap(err, "error opening database") + } + defer func() { + if dbShutdown, ok := authDB.(interface{ Shutdown() error }); ok { + dbShutdown.Shutdown() + } + }() + + // Get the nosql.DB interface from the wrapped DB + nosqlDB, ok := authDB.(*db.DB) + if !ok { + return errors.New("database does not support admin operations") + } + + // Initialize admin DB + adminDB, err := adminDBNosql.New(nosqlDB.DB, admin.DefaultAuthorityID) + if err != nil { + return errors.Wrap(err, "error initializing admin database") + } + + // Get existing provisioners for duplicate detection + existingProvs, err := adminDB.GetProvisioners(context.Background()) + if err != nil { + return errors.Wrap(err, "error getting existing provisioners") + } + + // Build map of existing provisioner names to IDs + existingProvsByName := make(map[string]string) + for _, p := range existingProvs { + existingProvsByName[p.Name] = p.Id + } + + // Get existing admins for duplicate detection + existingAdmins, err := adminDB.GetAdmins(context.Background()) + if err != nil { + return errors.Wrap(err, "error getting existing admins") + } + + // Build set of existing admin subject+provisioner combos + existingAdminKeys := make(map[string]bool) + for _, a := range existingAdmins { + key := a.Subject + ":" + a.ProvisionerId + existingAdminKeys[key] = true + } + + // Track old ID to new ID mappings for provisioners + provIDMap := make(map[string]string) + + // Import provisioners first (admins reference them) + fmt.Printf("Importing %d provisioner(s)...\n", len(export.Provisioners)) + var provsCreated, provsSkipped int + for _, prov := range export.Provisioners { + oldID := prov.Id + + // Check for duplicate by name + if existingID, exists := existingProvsByName[prov.Name]; exists { + fmt.Printf(" Skipping provisioner %q: already exists\n", prov.Name) + provIDMap[oldID] = existingID + provsSkipped++ + continue + } + + if dryRun { + fmt.Printf(" Would create provisioner %q (type: %s)\n", prov.Name, prov.Type.String()) + // For dry run, map old ID to itself since we won't create new ones + provIDMap[oldID] = oldID + provsCreated++ + continue + } + + // Clear ID so the database generates a new one + prov.Id = "" + + if err := adminDB.CreateProvisioner(context.Background(), prov); err != nil { + return errors.Wrapf(err, "error creating provisioner %q", prov.Name) + } + + fmt.Printf(" Created provisioner %q (type: %s)\n", prov.Name, prov.Type.String()) + provIDMap[oldID] = prov.Id + provsCreated++ + } + + // Import admins with remapped provisioner IDs + fmt.Printf("Importing %d admin(s)...\n", len(export.Admins)) + var adminsCreated, adminsSkipped int + for _, adm := range export.Admins { + // Remap provisioner ID + newProvID, ok := provIDMap[adm.ProvisionerId] + if !ok { + fmt.Printf(" Skipping admin %q: provisioner ID %s not found in export\n", adm.Subject, adm.ProvisionerId) + adminsSkipped++ + continue + } + + // Check for duplicate by subject+provisioner combo + key := adm.Subject + ":" + newProvID + if existingAdminKeys[key] { + fmt.Printf(" Skipping admin %q: already exists for this provisioner\n", adm.Subject) + adminsSkipped++ + continue + } + + if dryRun { + fmt.Printf(" Would create admin %q (type: %s)\n", adm.Subject, adm.Type.String()) + adminsCreated++ + continue + } + + // Clear ID and update provisioner ID + adm.Id = "" + adm.ProvisionerId = newProvID + + if err := adminDB.CreateAdmin(context.Background(), adm); err != nil { + return errors.Wrapf(err, "error creating admin %q", adm.Subject) + } + + fmt.Printf(" Created admin %q (type: %s)\n", adm.Subject, adm.Type.String()) + adminsCreated++ + } + + fmt.Println() + fmt.Printf("Import complete: %d provisioner(s) created, %d skipped; %d admin(s) created, %d skipped\n", + provsCreated, provsSkipped, adminsCreated, adminsSkipped) + + if dryRun { + fmt.Println() + fmt.Println("=== DRY RUN - No changes were made ===") + fmt.Println("Run without --dry-run to perform the import.") + } + + return nil +}