Skip to content

Commit ff2ac1e

Browse files
committed
feat: add flag to disable password auth
Adds a flag --disable-password-auth that prevents the password login endpoint from working unless the user has the "owner" (aka. site admin) role. Adds a subcommand `coder server create-admin-user` which creates a user directly in the database with the "owner" role, the "admin" role in every organization, and password auth. This is to avoid lock-out situations where all accounts have the login type set to an identity provider and nobody can login.
1 parent 01ebfdc commit ff2ac1e

19 files changed

+1232
-529
lines changed

cli/deployment/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,12 @@ func newConfig() *codersdk.DeploymentConfig {
531531
Flag: "disable-path-apps",
532532
Default: false,
533533
},
534+
DisablePasswordAuth: &codersdk.DeploymentConfigField[bool]{
535+
Name: "Disable Password Authentication",
536+
Usage: "Disable password authentication. This is recommended for security purposes in production deployments that rely on an identity provider. Any user with the owner role will be able to sign in with their password regardless of this setting to avoid potential lock out. If you are locked out of your account, you can use the `coder server create-admin` command to create a new admin user directly in the database.",
537+
Flag: "disable-password-auth",
538+
Default: false,
539+
},
534540
}
535541
}
536542

cli/server.go

Lines changed: 301 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,11 @@ import (
6565
"github.com/coder/coder/coderd/httpapi"
6666
"github.com/coder/coder/coderd/httpmw"
6767
"github.com/coder/coder/coderd/prometheusmetrics"
68+
"github.com/coder/coder/coderd/rbac"
6869
"github.com/coder/coder/coderd/telemetry"
6970
"github.com/coder/coder/coderd/tracing"
7071
"github.com/coder/coder/coderd/updatecheck"
72+
"github.com/coder/coder/coderd/userpassword"
7173
"github.com/coder/coder/codersdk"
7274
"github.com/coder/coder/cryptorand"
7375
"github.com/coder/coder/provisioner/echo"
@@ -561,62 +563,13 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
561563
options.Database = databasefake.New()
562564
options.Pubsub = database.NewPubsubInMemory()
563565
} else {
564-
logger.Debug(ctx, "connecting to postgresql")
565-
sqlDB, err := sql.Open(sqlDriver, cfg.PostgresURL.Value)
566+
sqlDB, err := connectToPostgres(ctx, logger, sqlDriver, cfg.PostgresURL.Value)
566567
if err != nil {
567-
return xerrors.Errorf("dial postgres: %w", err)
568+
return xerrors.Errorf("connect to postgres: %w", err)
568569
}
569-
defer sqlDB.Close()
570-
571-
pingCtx, pingCancel := context.WithTimeout(ctx, 15*time.Second)
572-
defer pingCancel()
573-
574-
err = sqlDB.PingContext(pingCtx)
575-
if err != nil {
576-
return xerrors.Errorf("ping postgres: %w", err)
577-
}
578-
579-
// Ensure the PostgreSQL version is >=13.0.0!
580-
version, err := sqlDB.QueryContext(ctx, "SHOW server_version;")
581-
if err != nil {
582-
return xerrors.Errorf("get postgres version: %w", err)
583-
}
584-
if !version.Next() {
585-
return xerrors.Errorf("no rows returned for version select")
586-
}
587-
var versionStr string
588-
err = version.Scan(&versionStr)
589-
if err != nil {
590-
return xerrors.Errorf("scan version: %w", err)
591-
}
592-
_ = version.Close()
593-
versionStr = strings.Split(versionStr, " ")[0]
594-
if semver.Compare("v"+versionStr, "v13") < 0 {
595-
return xerrors.New("PostgreSQL version must be v13.0.0 or higher!")
596-
}
597-
logger.Debug(ctx, "connected to postgresql", slog.F("version", versionStr))
598-
599-
err = migrations.Up(sqlDB)
600-
if err != nil {
601-
return xerrors.Errorf("migrate up: %w", err)
602-
}
603-
// The default is 0 but the request will fail with a 500 if the DB
604-
// cannot accept new connections, so we try to limit that here.
605-
// Requests will wait for a new connection instead of a hard error
606-
// if a limit is set.
607-
sqlDB.SetMaxOpenConns(10)
608-
// Allow a max of 3 idle connections at a time. Lower values end up
609-
// creating a lot of connection churn. Since each connection uses about
610-
// 10MB of memory, we're allocating 30MB to Postgres connections per
611-
// replica, but is better than causing Postgres to spawn a thread 15-20
612-
// times/sec. PGBouncer's transaction pooling is not the greatest so
613-
// it's not optimal for us to deploy.
614-
//
615-
// This was set to 10 before we started doing HA deployments, but 3 was
616-
// later determined to be a better middle ground as to not use up all
617-
// of PGs default connection limit while simultaneously avoiding a lot
618-
// of connection churn.
619-
sqlDB.SetMaxIdleConns(3)
570+
defer func() {
571+
_ = sqlDB.Close()
572+
}()
620573

621574
options.Database = database.New(sqlDB)
622575
options.Pubsub, err = database.NewPubsub(ctx, sqlDB, cfg.PostgresURL.Value)
@@ -1005,7 +958,232 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
1005958
postgresBuiltinURLCmd.Flags().BoolVar(&pgRawURL, "raw-url", false, "Output the raw connection URL instead of a psql command.")
1006959
postgresBuiltinServeCmd.Flags().BoolVar(&pgRawURL, "raw-url", false, "Output the raw connection URL instead of a psql command.")
1007960

1008-
root.AddCommand(postgresBuiltinURLCmd, postgresBuiltinServeCmd)
961+
var (
962+
newUserDBURL string
963+
newUserSSHKeygenAlgorithm string
964+
newUserUsername string
965+
newUserEmail string
966+
newUserPassword string
967+
)
968+
createAdminUserCommand := &cobra.Command{
969+
Use: "create-admin-user",
970+
Short: "Create a new admin user with the given username, email and password and adds it to every organization.",
971+
RunE: func(cmd *cobra.Command, args []string) error {
972+
ctx := cmd.Context()
973+
974+
sshKeygenAlgorithm, err := gitsshkey.ParseAlgorithm(newUserSSHKeygenAlgorithm)
975+
if err != nil {
976+
return xerrors.Errorf("parse ssh keygen algorithm %q: %w", newUserSSHKeygenAlgorithm, err)
977+
}
978+
979+
if val, exists := os.LookupEnv("CODER_POSTGRES_URL"); exists {
980+
newUserDBURL = val
981+
}
982+
if val, exists := os.LookupEnv("CODER_SSH_KEYGEN_ALGORITHM"); exists {
983+
newUserSSHKeygenAlgorithm = val
984+
}
985+
if val, exists := os.LookupEnv("CODER_USERNAME"); exists {
986+
newUserUsername = val
987+
}
988+
if val, exists := os.LookupEnv("CODER_EMAIL"); exists {
989+
newUserEmail = val
990+
}
991+
if val, exists := os.LookupEnv("CODER_PASSWORD"); exists {
992+
newUserPassword = val
993+
}
994+
995+
cfg := createConfig(cmd)
996+
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()))
997+
if ok, _ := cmd.Flags().GetBool(varVerbose); ok {
998+
logger = logger.Leveled(slog.LevelDebug)
999+
}
1000+
1001+
ctx, cancel := signal.NotifyContext(ctx, InterruptSignals...)
1002+
defer cancel()
1003+
1004+
if newUserDBURL == "" {
1005+
cmd.Printf("Using built-in PostgreSQL (%s)\n", cfg.PostgresPath())
1006+
url, closePg, err := startBuiltinPostgres(ctx, cfg, logger)
1007+
if err != nil {
1008+
return err
1009+
}
1010+
defer func() {
1011+
_ = closePg()
1012+
}()
1013+
newUserDBURL = url
1014+
}
1015+
1016+
sqlDB, err := connectToPostgres(ctx, logger, "postgres", newUserDBURL)
1017+
if err != nil {
1018+
return xerrors.Errorf("connect to postgres: %w", err)
1019+
}
1020+
defer func() {
1021+
_ = sqlDB.Close()
1022+
}()
1023+
db := database.New(sqlDB)
1024+
1025+
validateInputs := func(username, email, password string) error {
1026+
// Use the validator tags so we match the API's validation.
1027+
req := codersdk.CreateUserRequest{
1028+
Username: "username",
1029+
Email: "email@coder.com",
1030+
Password: "ValidPa$$word123!",
1031+
OrganizationID: uuid.New(),
1032+
}
1033+
if username != "" {
1034+
req.Username = username
1035+
}
1036+
if email != "" {
1037+
req.Email = email
1038+
}
1039+
if password != "" {
1040+
req.Password = password
1041+
}
1042+
1043+
return httpapi.Validate.Struct(req)
1044+
}
1045+
1046+
if newUserUsername == "" {
1047+
newUserUsername, err = cliui.Prompt(cmd, cliui.PromptOptions{
1048+
Text: "Username",
1049+
Validate: func(val string) error {
1050+
if val == "" {
1051+
return xerrors.New("username cannot be empty")
1052+
}
1053+
return validateInputs(val, "", "")
1054+
},
1055+
})
1056+
if err != nil {
1057+
return err
1058+
}
1059+
}
1060+
if newUserEmail == "" {
1061+
newUserEmail, err = cliui.Prompt(cmd, cliui.PromptOptions{
1062+
Text: "Email",
1063+
Validate: func(val string) error {
1064+
if val == "" {
1065+
return xerrors.New("email cannot be empty")
1066+
}
1067+
return validateInputs("", val, "")
1068+
},
1069+
})
1070+
if err != nil {
1071+
return err
1072+
}
1073+
}
1074+
if newUserPassword == "" {
1075+
newUserPassword, err = cliui.Prompt(cmd, cliui.PromptOptions{
1076+
Text: "Password",
1077+
Secret: true,
1078+
Validate: func(val string) error {
1079+
if val == "" {
1080+
return xerrors.New("password cannot be empty")
1081+
}
1082+
return validateInputs("", "", val)
1083+
},
1084+
})
1085+
if err != nil {
1086+
return err
1087+
}
1088+
1089+
// Prompt again.
1090+
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
1091+
Text: "Confirm password",
1092+
Secret: true,
1093+
Validate: func(val string) error {
1094+
if val != newUserPassword {
1095+
return xerrors.New("passwords do not match")
1096+
}
1097+
return nil
1098+
},
1099+
})
1100+
if err != nil {
1101+
return err
1102+
}
1103+
}
1104+
1105+
err = validateInputs(newUserUsername, newUserEmail, newUserPassword)
1106+
if err != nil {
1107+
return xerrors.Errorf("validate inputs: %w", err)
1108+
}
1109+
1110+
hashedPassword, err := userpassword.Hash(newUserPassword)
1111+
if err != nil {
1112+
return xerrors.Errorf("hash password: %w", err)
1113+
}
1114+
1115+
// Create the user.
1116+
var newUser database.User
1117+
err = db.InTx(func(tx database.Store) error {
1118+
orgs, err := tx.GetOrganizations(ctx)
1119+
if err != nil {
1120+
return xerrors.Errorf("get organizations: %w", err)
1121+
}
1122+
1123+
newUser, err = tx.InsertUser(ctx, database.InsertUserParams{
1124+
ID: uuid.New(),
1125+
Email: newUserEmail,
1126+
Username: newUserUsername,
1127+
HashedPassword: []byte(hashedPassword),
1128+
CreatedAt: database.Now(),
1129+
UpdatedAt: database.Now(),
1130+
RBACRoles: []string{rbac.RoleOwner()},
1131+
LoginType: database.LoginTypePassword,
1132+
})
1133+
if err != nil {
1134+
return xerrors.Errorf("insert user: %w", err)
1135+
}
1136+
1137+
privateKey, publicKey, err := gitsshkey.Generate(sshKeygenAlgorithm)
1138+
if err != nil {
1139+
return xerrors.Errorf("generate user gitsshkey: %w", err)
1140+
}
1141+
_, err = tx.InsertGitSSHKey(ctx, database.InsertGitSSHKeyParams{
1142+
UserID: newUser.ID,
1143+
CreatedAt: database.Now(),
1144+
UpdatedAt: database.Now(),
1145+
PrivateKey: privateKey,
1146+
PublicKey: publicKey,
1147+
})
1148+
if err != nil {
1149+
return xerrors.Errorf("insert user gitsshkey: %w", err)
1150+
}
1151+
1152+
for _, org := range orgs {
1153+
_, err := tx.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{
1154+
OrganizationID: org.ID,
1155+
UserID: newUser.ID,
1156+
CreatedAt: database.Now(),
1157+
UpdatedAt: database.Now(),
1158+
Roles: []string{rbac.RoleOrgAdmin(org.ID)},
1159+
})
1160+
if err != nil {
1161+
return xerrors.Errorf("insert organization member: %w", err)
1162+
}
1163+
}
1164+
1165+
return nil
1166+
}, nil)
1167+
if err != nil {
1168+
return err
1169+
}
1170+
1171+
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "User created successfully.")
1172+
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "ID: "+newUser.ID.String())
1173+
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Username: "+newUser.Username)
1174+
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Email: "+newUser.Email)
1175+
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Password: ********")
1176+
1177+
return nil
1178+
},
1179+
}
1180+
createAdminUserCommand.Flags().StringVar(&newUserDBURL, "postgres-url", "", "URL of a PostgreSQL database. If empty, the built-in PostgreSQL deployment will be used (Coder must not be already running in this case). Consumes $CODER_POSTGRES_URL.")
1181+
createAdminUserCommand.Flags().StringVar(&newUserSSHKeygenAlgorithm, "ssh-keygen-algorithm", "ed25519", "The algorithm to use for generating ssh keys. Accepted values are \"ed25519\", \"ecdsa\", or \"rsa4096\". Consumes $CODER_SSH_KEYGEN_ALGORITHM.")
1182+
createAdminUserCommand.Flags().StringVar(&newUserUsername, "username", "", "The username of the new user. If not specified, you will be prompted via stdin. Consumes $CODER_USERNAME.")
1183+
createAdminUserCommand.Flags().StringVar(&newUserEmail, "email", "", "The email of the new user. If not specified, you will be prompted via stdin. Consumes $CODER_EMAIL.")
1184+
createAdminUserCommand.Flags().StringVar(&newUserPassword, "password", "", "The password of the new user. If not specified, you will be prompted via stdin. Consumes $CODER_PASSWORD.")
1185+
1186+
root.AddCommand(postgresBuiltinURLCmd, postgresBuiltinServeCmd, createAdminUserCommand)
10091187

10101188
deployment.AttachFlags(root.Flags(), vip, false)
10111189

@@ -1560,3 +1738,71 @@ func buildLogger(cmd *cobra.Command, cfg *codersdk.DeploymentConfig) (slog.Logge
15601738
}
15611739
}, nil
15621740
}
1741+
1742+
func connectToPostgres(ctx context.Context, logger slog.Logger, driver string, dbURL string) (*sql.DB, error) {
1743+
logger.Debug(ctx, "connecting to postgresql")
1744+
sqlDB, err := sql.Open(driver, dbURL)
1745+
if err != nil {
1746+
return nil, xerrors.Errorf("dial postgres: %w", err)
1747+
}
1748+
1749+
ok := false
1750+
defer func() {
1751+
if !ok {
1752+
_ = sqlDB.Close()
1753+
}
1754+
}()
1755+
1756+
pingCtx, pingCancel := context.WithTimeout(ctx, 15*time.Second)
1757+
defer pingCancel()
1758+
1759+
err = sqlDB.PingContext(pingCtx)
1760+
if err != nil {
1761+
return nil, xerrors.Errorf("ping postgres: %w", err)
1762+
}
1763+
1764+
// Ensure the PostgreSQL version is >=13.0.0!
1765+
version, err := sqlDB.QueryContext(ctx, "SHOW server_version;")
1766+
if err != nil {
1767+
return nil, xerrors.Errorf("get postgres version: %w", err)
1768+
}
1769+
if !version.Next() {
1770+
return nil, xerrors.Errorf("no rows returned for version select")
1771+
}
1772+
var versionStr string
1773+
err = version.Scan(&versionStr)
1774+
if err != nil {
1775+
return nil, xerrors.Errorf("scan version: %w", err)
1776+
}
1777+
_ = version.Close()
1778+
versionStr = strings.Split(versionStr, " ")[0]
1779+
if semver.Compare("v"+versionStr, "v13") < 0 {
1780+
return nil, xerrors.New("PostgreSQL version must be v13.0.0 or higher!")
1781+
}
1782+
logger.Debug(ctx, "connected to postgresql", slog.F("version", versionStr))
1783+
1784+
err = migrations.Up(sqlDB)
1785+
if err != nil {
1786+
return nil, xerrors.Errorf("migrate up: %w", err)
1787+
}
1788+
// The default is 0 but the request will fail with a 500 if the DB
1789+
// cannot accept new connections, so we try to limit that here.
1790+
// Requests will wait for a new connection instead of a hard error
1791+
// if a limit is set.
1792+
sqlDB.SetMaxOpenConns(10)
1793+
// Allow a max of 3 idle connections at a time. Lower values end up
1794+
// creating a lot of connection churn. Since each connection uses about
1795+
// 10MB of memory, we're allocating 30MB to Postgres connections per
1796+
// replica, but is better than causing Postgres to spawn a thread 15-20
1797+
// times/sec. PGBouncer's transaction pooling is not the greatest so
1798+
// it's not optimal for us to deploy.
1799+
//
1800+
// This was set to 10 before we started doing HA deployments, but 3 was
1801+
// later determined to be a better middle ground as to not use up all
1802+
// of PGs default connection limit while simultaneously avoiding a lot
1803+
// of connection churn.
1804+
sqlDB.SetMaxIdleConns(3)
1805+
1806+
ok = true
1807+
return sqlDB, nil
1808+
}

0 commit comments

Comments
 (0)