diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 12a7321189806..c88e2f16d2c46 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11921,6 +11921,7 @@ const docTemplate = `{ "assign_org_role", "assign_role", "audit_log", + "crypto_key", "debug_info", "deployment_config", "deployment_stats", @@ -11953,6 +11954,7 @@ const docTemplate = `{ "ResourceAssignOrgRole", "ResourceAssignRole", "ResourceAuditLog", + "ResourceCryptoKey", "ResourceDebugInfo", "ResourceDeploymentConfig", "ResourceDeploymentStats", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 23a1f369c5181..52da876ce4561 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -10761,6 +10761,7 @@ "assign_org_role", "assign_role", "audit_log", + "crypto_key", "debug_info", "deployment_config", "deployment_stats", @@ -10793,6 +10794,7 @@ "ResourceAssignOrgRole", "ResourceAssignRole", "ResourceAuditLog", + "ResourceCryptoKey", "ResourceDebugInfo", "ResourceDeploymentConfig", "ResourceDeploymentStats", diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 243e39487f71e..0fb5001b9a075 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -253,6 +253,7 @@ var ( rbac.ResourceDeploymentConfig.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, rbac.ResourceNotificationPreference.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, rbac.ResourceNotificationTemplate.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceCryptoKey.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, }), Org: map[string][]rbac.Permission{}, User: []rbac.Permission{}, @@ -1041,6 +1042,13 @@ func (q *querier) DeleteCoordinator(ctx context.Context, id uuid.UUID) error { return q.db.DeleteCoordinator(ctx, id) } +func (q *querier) DeleteCryptoKey(ctx context.Context, arg database.DeleteCryptoKeyParams) (database.CryptoKey, error) { + if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceCryptoKey); err != nil { + return database.CryptoKey{}, err + } + return q.db.DeleteCryptoKey(ctx, arg) +} + func (q *querier) DeleteCustomRole(ctx context.Context, arg database.DeleteCustomRoleParams) error { if arg.OrganizationID.UUID != uuid.Nil { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil { @@ -1383,6 +1391,20 @@ func (q *querier) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (stri return q.db.GetCoordinatorResumeTokenSigningKey(ctx) } +func (q *querier) GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg database.GetCryptoKeyByFeatureAndSequenceParams) (database.CryptoKey, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceCryptoKey); err != nil { + return database.CryptoKey{}, err + } + return q.db.GetCryptoKeyByFeatureAndSequence(ctx, arg) +} + +func (q *querier) GetCryptoKeys(ctx context.Context) ([]database.CryptoKey, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceCryptoKey); err != nil { + return nil, err + } + return q.db.GetCryptoKeys(ctx) +} + func (q *querier) GetDBCryptKeys(ctx context.Context) ([]database.DBCryptKey, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { return nil, err @@ -1549,6 +1571,13 @@ func (q *querier) GetLastUpdateCheck(ctx context.Context) (string, error) { return q.db.GetLastUpdateCheck(ctx) } +func (q *querier) GetLatestCryptoKeyByFeature(ctx context.Context, feature database.CryptoKeyFeature) (database.CryptoKey, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceCryptoKey); err != nil { + return database.CryptoKey{}, err + } + return q.db.GetLatestCryptoKeyByFeature(ctx, feature) +} + func (q *querier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { if _, err := q.GetWorkspaceByID(ctx, workspaceID); err != nil { return database.WorkspaceBuild{}, err @@ -2662,6 +2691,13 @@ func (q *querier) InsertAuditLog(ctx context.Context, arg database.InsertAuditLo return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertAuditLog)(ctx, arg) } +func (q *querier) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { + if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceCryptoKey); err != nil { + return database.CryptoKey{}, err + } + return q.db.InsertCryptoKey(ctx, arg) +} + func (q *querier) InsertCustomRole(ctx context.Context, arg database.InsertCustomRoleParams) (database.CustomRole, error) { // Org and site role upsert share the same query. So switch the assertion based on the org uuid. if arg.OrganizationID.UUID != uuid.Nil { @@ -3169,6 +3205,13 @@ func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKe return update(q.log, q.auth, fetch, q.db.UpdateAPIKeyByID)(ctx, arg) } +func (q *querier) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceCryptoKey); err != nil { + return database.CryptoKey{}, err + } + return q.db.UpdateCryptoKeyDeletesAt(ctx, arg) +} + func (q *querier) UpdateCustomRole(ctx context.Context, arg database.UpdateCustomRoleParams) (database.CustomRole, error) { if arg.OrganizationID.UUID != uuid.Nil { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 5cbec26623efc..8cdcf4fef1ab8 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2253,6 +2253,57 @@ func (s *MethodTestSuite) TestDBCrypt() { })) } +func (s *MethodTestSuite) TestCryptoKeys() { + s.Run("GetCryptoKeys", s.Subtest(func(db database.Store, check *expects) { + check.Args(). + Asserts(rbac.ResourceCryptoKey, policy.ActionRead) + })) + s.Run("InsertCryptoKey", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.InsertCryptoKeyParams{ + Feature: database.CryptoKeyFeatureWorkspaceApps, + }). + Asserts(rbac.ResourceCryptoKey, policy.ActionCreate) + })) + s.Run("DeleteCryptoKey", s.Subtest(func(db database.Store, check *expects) { + key := dbgen.CryptoKey(s.T(), db, database.CryptoKey{ + Feature: database.CryptoKeyFeatureWorkspaceApps, + Sequence: 4, + }) + check.Args(database.DeleteCryptoKeyParams{ + Feature: key.Feature, + Sequence: key.Sequence, + }).Asserts(rbac.ResourceCryptoKey, policy.ActionDelete) + })) + s.Run("GetCryptoKeyByFeatureAndSequence", s.Subtest(func(db database.Store, check *expects) { + key := dbgen.CryptoKey(s.T(), db, database.CryptoKey{ + Feature: database.CryptoKeyFeatureWorkspaceApps, + Sequence: 4, + }) + check.Args(database.GetCryptoKeyByFeatureAndSequenceParams{ + Feature: key.Feature, + Sequence: key.Sequence, + }).Asserts(rbac.ResourceCryptoKey, policy.ActionRead).Returns(key) + })) + s.Run("GetLatestCryptoKeyByFeature", s.Subtest(func(db database.Store, check *expects) { + dbgen.CryptoKey(s.T(), db, database.CryptoKey{ + Feature: database.CryptoKeyFeatureWorkspaceApps, + Sequence: 4, + }) + check.Args(database.CryptoKeyFeatureWorkspaceApps).Asserts(rbac.ResourceCryptoKey, policy.ActionRead) + })) + s.Run("UpdateCryptoKeyDeletesAt", s.Subtest(func(db database.Store, check *expects) { + key := dbgen.CryptoKey(s.T(), db, database.CryptoKey{ + Feature: database.CryptoKeyFeatureWorkspaceApps, + Sequence: 4, + }) + check.Args(database.UpdateCryptoKeyDeletesAtParams{ + Feature: key.Feature, + Sequence: key.Sequence, + DeletesAt: sql.NullTime{Time: time.Now(), Valid: true}, + }).Asserts(rbac.ResourceCryptoKey, policy.ActionUpdate) + })) +} + func (s *MethodTestSuite) TestSystemFunctions() { s.Run("UpdateUserLinkedID", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 9bfc05a041da2..3c027675b231d 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -2,6 +2,7 @@ package dbgen import ( "context" + "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" @@ -16,6 +17,7 @@ import ( "github.com/google/uuid" "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" + "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" @@ -893,6 +895,40 @@ func CustomRole(t testing.TB, db database.Store, seed database.CustomRole) datab return role } +func CryptoKey(t testing.TB, db database.Store, seed database.CryptoKey) database.CryptoKey { + t.Helper() + + seed.Feature = takeFirst(seed.Feature, database.CryptoKeyFeatureWorkspaceApps) + + if !seed.Secret.Valid { + secret, err := newCryptoKeySecret(seed.Feature) + require.NoError(t, err, "generate secret") + seed.Secret = sql.NullString{ + String: secret, + Valid: true, + } + } + + key, err := db.InsertCryptoKey(genCtx, database.InsertCryptoKeyParams{ + Sequence: takeFirst(seed.Sequence, 123), + Secret: seed.Secret, + SecretKeyID: takeFirst(seed.SecretKeyID, sql.NullString{}), + Feature: seed.Feature, + StartsAt: takeFirst(seed.StartsAt, time.Now()), + }) + require.NoError(t, err, "insert crypto key") + + if seed.DeletesAt.Valid { + key, err = db.UpdateCryptoKeyDeletesAt(genCtx, database.UpdateCryptoKeyDeletesAtParams{ + Feature: key.Feature, + Sequence: key.Sequence, + DeletesAt: sql.NullTime{Time: seed.DeletesAt.Time, Valid: true}, + }) + require.NoError(t, err, "update crypto key deletes_at") + } + return key +} + func ProvisionerJobTimings(t testing.TB, db database.Store, seed database.InsertProvisionerJobTimingsParams) []database.ProvisionerJobTiming { timings, err := db.InsertProvisionerJobTimings(genCtx, seed) require.NoError(t, err, "insert provisioner job timings") @@ -942,3 +978,24 @@ func takeFirst[Value comparable](values ...Value) Value { return v != empty }) } + +func newCryptoKeySecret(feature database.CryptoKeyFeature) (string, error) { + switch feature { + case database.CryptoKeyFeatureWorkspaceApps: + return generateCryptoKey(96) + case database.CryptoKeyFeatureOidcConvert: + return generateCryptoKey(32) + case database.CryptoKeyFeatureTailnetResume: + return generateCryptoKey(64) + } + return "", xerrors.Errorf("unknown feature: %s", feature) +} + +func generateCryptoKey(length int) (string, error) { + b := make([]byte, length) + _, err := rand.Read(b) + if err != nil { + return "", xerrors.Errorf("rand read: %w", err) + } + return hex.EncodeToString(b), nil +} diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 1275f2c881643..b0a4703b5c4e2 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -189,6 +189,7 @@ type data struct { // New tables workspaceAgentStats []database.WorkspaceAgentStat auditLogs []database.AuditLog + cryptoKeys []database.CryptoKey dbcryptKeys []database.DBCryptKey files []database.File externalAuthLinks []database.ExternalAuthLink @@ -1471,6 +1472,27 @@ func (*FakeQuerier) DeleteCoordinator(context.Context, uuid.UUID) error { return ErrUnimplemented } +func (q *FakeQuerier) DeleteCryptoKey(_ context.Context, arg database.DeleteCryptoKeyParams) (database.CryptoKey, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.CryptoKey{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, key := range q.cryptoKeys { + if key.Feature == arg.Feature && key.Sequence == arg.Sequence { + q.cryptoKeys[i].Secret.String = "" + q.cryptoKeys[i].Secret.Valid = false + q.cryptoKeys[i].SecretKeyID.String = "" + q.cryptoKeys[i].SecretKeyID.Valid = false + return q.cryptoKeys[i], nil + } + } + return database.CryptoKey{}, sql.ErrNoRows +} + func (q *FakeQuerier) DeleteCustomRole(_ context.Context, arg database.DeleteCustomRoleParams) error { err := validateDatabaseType(arg) if err != nil { @@ -2346,6 +2368,41 @@ func (q *FakeQuerier) GetCoordinatorResumeTokenSigningKey(_ context.Context) (st return q.coordinatorResumeTokenSigningKey, nil } +func (q *FakeQuerier) GetCryptoKeyByFeatureAndSequence(_ context.Context, arg database.GetCryptoKeyByFeatureAndSequenceParams) (database.CryptoKey, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.CryptoKey{}, err + } + + q.mutex.RLock() + defer q.mutex.RUnlock() + + for _, key := range q.cryptoKeys { + if key.Feature == arg.Feature && key.Sequence == arg.Sequence { + // Keys with NULL secrets are considered deleted. + if key.Secret.Valid { + return key, nil + } + return database.CryptoKey{}, sql.ErrNoRows + } + } + + return database.CryptoKey{}, sql.ErrNoRows +} + +func (q *FakeQuerier) GetCryptoKeys(_ context.Context) ([]database.CryptoKey, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + keys := make([]database.CryptoKey, 0) + for _, key := range q.cryptoKeys { + if key.Secret.Valid { + keys = append(keys, key) + } + } + return keys, nil +} + func (q *FakeQuerier) GetDBCryptKeys(_ context.Context) ([]database.DBCryptKey, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -2849,6 +2906,22 @@ func (q *FakeQuerier) GetLastUpdateCheck(_ context.Context) (string, error) { return string(q.lastUpdateCheck), nil } +func (q *FakeQuerier) GetLatestCryptoKeyByFeature(_ context.Context, feature database.CryptoKeyFeature) (database.CryptoKey, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + var latestKey database.CryptoKey + for _, key := range q.cryptoKeys { + if key.Feature == feature && latestKey.Sequence < key.Sequence { + latestKey = key + } + } + if latestKey.StartsAt.IsZero() { + return database.CryptoKey{}, sql.ErrNoRows + } + return latestKey, nil +} + func (q *FakeQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -6368,6 +6441,28 @@ func (q *FakeQuerier) InsertAuditLog(_ context.Context, arg database.InsertAudit return alog, nil } +func (q *FakeQuerier) InsertCryptoKey(_ context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.CryptoKey{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + key := database.CryptoKey{ + Feature: arg.Feature, + Sequence: arg.Sequence, + Secret: arg.Secret, + SecretKeyID: arg.SecretKeyID, + StartsAt: arg.StartsAt, + } + + q.cryptoKeys = append(q.cryptoKeys, key) + + return key, nil +} + func (q *FakeQuerier) InsertCustomRole(_ context.Context, arg database.InsertCustomRoleParams) (database.CustomRole, error) { err := validateDatabaseType(arg) if err != nil { @@ -7874,6 +7969,25 @@ func (q *FakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPI return sql.ErrNoRows } +func (q *FakeQuerier) UpdateCryptoKeyDeletesAt(_ context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.CryptoKey{}, err + } + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, key := range q.cryptoKeys { + if key.Feature == arg.Feature && key.Sequence == arg.Sequence { + key.DeletesAt = arg.DeletesAt + q.cryptoKeys[i] = key + return key, nil + } + } + + return database.CryptoKey{}, sql.ErrNoRows +} + func (q *FakeQuerier) UpdateCustomRole(_ context.Context, arg database.UpdateCustomRoleParams) (database.CustomRole, error) { err := validateDatabaseType(arg) if err != nil { diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index e720a906be283..b6d77700eccc2 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -214,6 +214,13 @@ func (m metricsStore) DeleteCoordinator(ctx context.Context, id uuid.UUID) error return r0 } +func (m metricsStore) DeleteCryptoKey(ctx context.Context, arg database.DeleteCryptoKeyParams) (database.CryptoKey, error) { + start := time.Now() + r0, r1 := m.s.DeleteCryptoKey(ctx, arg) + m.queryLatencies.WithLabelValues("DeleteCryptoKey").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) DeleteCustomRole(ctx context.Context, arg database.DeleteCustomRoleParams) error { start := time.Now() r0 := m.s.DeleteCustomRole(ctx, arg) @@ -543,6 +550,20 @@ func (m metricsStore) GetCoordinatorResumeTokenSigningKey(ctx context.Context) ( return r0, r1 } +func (m metricsStore) GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg database.GetCryptoKeyByFeatureAndSequenceParams) (database.CryptoKey, error) { + start := time.Now() + r0, r1 := m.s.GetCryptoKeyByFeatureAndSequence(ctx, arg) + m.queryLatencies.WithLabelValues("GetCryptoKeyByFeatureAndSequence").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m metricsStore) GetCryptoKeys(ctx context.Context) ([]database.CryptoKey, error) { + start := time.Now() + r0, r1 := m.s.GetCryptoKeys(ctx) + m.queryLatencies.WithLabelValues("GetCryptoKeys").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetDBCryptKeys(ctx context.Context) ([]database.DBCryptKey, error) { start := time.Now() r0, r1 := m.s.GetDBCryptKeys(ctx) @@ -711,6 +732,13 @@ func (m metricsStore) GetLastUpdateCheck(ctx context.Context) (string, error) { return version, err } +func (m metricsStore) GetLatestCryptoKeyByFeature(ctx context.Context, feature database.CryptoKeyFeature) (database.CryptoKey, error) { + start := time.Now() + r0, r1 := m.s.GetLatestCryptoKeyByFeature(ctx, feature) + m.queryLatencies.WithLabelValues("GetLatestCryptoKeyByFeature").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { start := time.Now() build, err := m.s.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspaceID) @@ -1600,6 +1628,13 @@ func (m metricsStore) InsertAuditLog(ctx context.Context, arg database.InsertAud return log, err } +func (m metricsStore) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { + start := time.Now() + key, err := m.s.InsertCryptoKey(ctx, arg) + m.queryLatencies.WithLabelValues("InsertCryptoKey").Observe(time.Since(start).Seconds()) + return key, err +} + func (m metricsStore) InsertCustomRole(ctx context.Context, arg database.InsertCustomRoleParams) (database.CustomRole, error) { start := time.Now() r0, r1 := m.s.InsertCustomRole(ctx, arg) @@ -2006,6 +2041,13 @@ func (m metricsStore) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateA return err } +func (m metricsStore) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { + start := time.Now() + key, err := m.s.UpdateCryptoKeyDeletesAt(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateCryptoKeyDeletesAt").Observe(time.Since(start).Seconds()) + return key, err +} + func (m metricsStore) UpdateCustomRole(ctx context.Context, arg database.UpdateCustomRoleParams) (database.CustomRole, error) { start := time.Now() r0, r1 := m.s.UpdateCustomRole(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 018d2e9cfddeb..9608865d2844e 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -317,6 +317,21 @@ func (mr *MockStoreMockRecorder) DeleteCoordinator(arg0, arg1 any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCoordinator", reflect.TypeOf((*MockStore)(nil).DeleteCoordinator), arg0, arg1) } +// DeleteCryptoKey mocks base method. +func (m *MockStore) DeleteCryptoKey(arg0 context.Context, arg1 database.DeleteCryptoKeyParams) (database.CryptoKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteCryptoKey", arg0, arg1) + ret0, _ := ret[0].(database.CryptoKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteCryptoKey indicates an expected call of DeleteCryptoKey. +func (mr *MockStoreMockRecorder) DeleteCryptoKey(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCryptoKey", reflect.TypeOf((*MockStore)(nil).DeleteCryptoKey), arg0, arg1) +} + // DeleteCustomRole mocks base method. func (m *MockStore) DeleteCustomRole(arg0 context.Context, arg1 database.DeleteCustomRoleParams) error { m.ctrl.T.Helper() @@ -1058,6 +1073,36 @@ func (mr *MockStoreMockRecorder) GetCoordinatorResumeTokenSigningKey(arg0 any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCoordinatorResumeTokenSigningKey", reflect.TypeOf((*MockStore)(nil).GetCoordinatorResumeTokenSigningKey), arg0) } +// GetCryptoKeyByFeatureAndSequence mocks base method. +func (m *MockStore) GetCryptoKeyByFeatureAndSequence(arg0 context.Context, arg1 database.GetCryptoKeyByFeatureAndSequenceParams) (database.CryptoKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCryptoKeyByFeatureAndSequence", arg0, arg1) + ret0, _ := ret[0].(database.CryptoKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCryptoKeyByFeatureAndSequence indicates an expected call of GetCryptoKeyByFeatureAndSequence. +func (mr *MockStoreMockRecorder) GetCryptoKeyByFeatureAndSequence(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCryptoKeyByFeatureAndSequence", reflect.TypeOf((*MockStore)(nil).GetCryptoKeyByFeatureAndSequence), arg0, arg1) +} + +// GetCryptoKeys mocks base method. +func (m *MockStore) GetCryptoKeys(arg0 context.Context) ([]database.CryptoKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCryptoKeys", arg0) + ret0, _ := ret[0].([]database.CryptoKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCryptoKeys indicates an expected call of GetCryptoKeys. +func (mr *MockStoreMockRecorder) GetCryptoKeys(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCryptoKeys", reflect.TypeOf((*MockStore)(nil).GetCryptoKeys), arg0) +} + // GetDBCryptKeys mocks base method. func (m *MockStore) GetDBCryptKeys(arg0 context.Context) ([]database.DBCryptKey, error) { m.ctrl.T.Helper() @@ -1418,6 +1463,21 @@ func (mr *MockStoreMockRecorder) GetLastUpdateCheck(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLastUpdateCheck", reflect.TypeOf((*MockStore)(nil).GetLastUpdateCheck), arg0) } +// GetLatestCryptoKeyByFeature mocks base method. +func (m *MockStore) GetLatestCryptoKeyByFeature(arg0 context.Context, arg1 database.CryptoKeyFeature) (database.CryptoKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLatestCryptoKeyByFeature", arg0, arg1) + ret0, _ := ret[0].(database.CryptoKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLatestCryptoKeyByFeature indicates an expected call of GetLatestCryptoKeyByFeature. +func (mr *MockStoreMockRecorder) GetLatestCryptoKeyByFeature(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestCryptoKeyByFeature", reflect.TypeOf((*MockStore)(nil).GetLatestCryptoKeyByFeature), arg0, arg1) +} + // GetLatestWorkspaceBuildByWorkspaceID mocks base method. func (m *MockStore) GetLatestWorkspaceBuildByWorkspaceID(arg0 context.Context, arg1 uuid.UUID) (database.WorkspaceBuild, error) { m.ctrl.T.Helper() @@ -3367,6 +3427,21 @@ func (mr *MockStoreMockRecorder) InsertAuditLog(arg0, arg1 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAuditLog", reflect.TypeOf((*MockStore)(nil).InsertAuditLog), arg0, arg1) } +// InsertCryptoKey mocks base method. +func (m *MockStore) InsertCryptoKey(arg0 context.Context, arg1 database.InsertCryptoKeyParams) (database.CryptoKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertCryptoKey", arg0, arg1) + ret0, _ := ret[0].(database.CryptoKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertCryptoKey indicates an expected call of InsertCryptoKey. +func (mr *MockStoreMockRecorder) InsertCryptoKey(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertCryptoKey", reflect.TypeOf((*MockStore)(nil).InsertCryptoKey), arg0, arg1) +} + // InsertCustomRole mocks base method. func (m *MockStore) InsertCustomRole(arg0 context.Context, arg1 database.InsertCustomRoleParams) (database.CustomRole, error) { m.ctrl.T.Helper() @@ -4234,6 +4309,21 @@ func (mr *MockStoreMockRecorder) UpdateAPIKeyByID(arg0, arg1 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAPIKeyByID", reflect.TypeOf((*MockStore)(nil).UpdateAPIKeyByID), arg0, arg1) } +// UpdateCryptoKeyDeletesAt mocks base method. +func (m *MockStore) UpdateCryptoKeyDeletesAt(arg0 context.Context, arg1 database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateCryptoKeyDeletesAt", arg0, arg1) + ret0, _ := ret[0].(database.CryptoKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateCryptoKeyDeletesAt indicates an expected call of UpdateCryptoKeyDeletesAt. +func (mr *MockStoreMockRecorder) UpdateCryptoKeyDeletesAt(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCryptoKeyDeletesAt", reflect.TypeOf((*MockStore)(nil).UpdateCryptoKeyDeletesAt), arg0, arg1) +} + // UpdateCustomRole mocks base method. func (m *MockStore) UpdateCustomRole(arg0 context.Context, arg1 database.UpdateCustomRoleParams) (database.CustomRole, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 4bf3aab10abd6..efa4dfb012a3d 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -36,6 +36,12 @@ CREATE TYPE build_reason AS ENUM ( 'autodelete' ); +CREATE TYPE crypto_key_feature AS ENUM ( + 'workspace_apps', + 'oidc_convert', + 'tailnet_resume' +); + CREATE TYPE display_app AS ENUM ( 'vscode', 'vscode_insiders', @@ -494,6 +500,15 @@ CREATE TABLE audit_logs ( resource_icon text NOT NULL ); +CREATE TABLE crypto_keys ( + feature crypto_key_feature NOT NULL, + sequence integer NOT NULL, + secret text, + secret_key_id text, + starts_at timestamp with time zone NOT NULL, + deletes_at timestamp with time zone +); + CREATE TABLE custom_roles ( name text NOT NULL, display_name text NOT NULL, @@ -1641,6 +1656,9 @@ ALTER TABLE ONLY api_keys ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); +ALTER TABLE ONLY crypto_keys + ADD CONSTRAINT crypto_keys_pkey PRIMARY KEY (feature, sequence); + ALTER TABLE ONLY custom_roles ADD CONSTRAINT custom_roles_unique_key UNIQUE (name, organization_id); @@ -2036,6 +2054,9 @@ CREATE TRIGGER update_notification_message_dedupe_hash BEFORE INSERT OR UPDATE O ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY crypto_keys + ADD CONSTRAINT crypto_keys_secret_key_id_fkey FOREIGN KEY (secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 7c9f3f55c7a76..907cebed6d8e0 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -7,6 +7,7 @@ type ForeignKeyConstraint string // ForeignKeyConstraint enums. const ( ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyCryptoKeysSecretKeyID ForeignKeyConstraint = "crypto_keys_secret_key_id_fkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_secret_key_id_fkey FOREIGN KEY (secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyGitAuthLinksOauthAccessTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyGitAuthLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyGitSSHKeysUserID ForeignKeyConstraint = "gitsshkeys_user_id_fkey" // ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); diff --git a/coderd/database/migrations/000251_crypto_keys.down.sql b/coderd/database/migrations/000251_crypto_keys.down.sql new file mode 100644 index 0000000000000..3972e177480e8 --- /dev/null +++ b/coderd/database/migrations/000251_crypto_keys.down.sql @@ -0,0 +1,2 @@ +DROP TABLE "crypto_keys"; +DROP TYPE "crypto_key_feature"; diff --git a/coderd/database/migrations/000251_crypto_keys.up.sql b/coderd/database/migrations/000251_crypto_keys.up.sql new file mode 100644 index 0000000000000..cc478f461c763 --- /dev/null +++ b/coderd/database/migrations/000251_crypto_keys.up.sql @@ -0,0 +1,16 @@ +CREATE TYPE crypto_key_feature AS ENUM ( + 'workspace_apps', + 'oidc_convert', + 'tailnet_resume' +); + +CREATE TABLE crypto_keys ( + feature crypto_key_feature NOT NULL, + sequence integer NOT NULL, + secret text NULL, + secret_key_id text NULL REFERENCES dbcrypt_keys(active_key_digest), + starts_at timestamptz NOT NULL, + deletes_at timestamptz NULL, + PRIMARY KEY (feature, sequence) +); + diff --git a/coderd/database/migrations/testdata/fixtures/000251_crypto_keys.up.sql b/coderd/database/migrations/testdata/fixtures/000251_crypto_keys.up.sql new file mode 100644 index 0000000000000..b50f73a4b3553 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000251_crypto_keys.up.sql @@ -0,0 +1,4 @@ +INSERT INTO crypto_keys (feature, sequence, starts_at, secret) VALUES +('workspace_apps', 1, now(), 'abc'), +('oidc_convert', 1, now(), 'def'), +('tailnet_resume', 1, now(), 'ghi'); diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 816fc4c9214b0..82be5e710c058 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -447,3 +447,7 @@ func (r GetAuthorizationUserRolesRow) RoleNames() ([]rbac.RoleIdentifier, error) } return names, nil } + +func (k CryptoKey) ExpiresAt(keyDuration time.Duration) time.Time { + return k.StartsAt.Add(keyDuration).UTC() +} diff --git a/coderd/database/models.go b/coderd/database/models.go index 4ee2f82818d7a..9c00309b11aa2 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -339,6 +339,67 @@ func AllBuildReasonValues() []BuildReason { } } +type CryptoKeyFeature string + +const ( + CryptoKeyFeatureWorkspaceApps CryptoKeyFeature = "workspace_apps" + CryptoKeyFeatureOidcConvert CryptoKeyFeature = "oidc_convert" + CryptoKeyFeatureTailnetResume CryptoKeyFeature = "tailnet_resume" +) + +func (e *CryptoKeyFeature) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = CryptoKeyFeature(s) + case string: + *e = CryptoKeyFeature(s) + default: + return fmt.Errorf("unsupported scan type for CryptoKeyFeature: %T", src) + } + return nil +} + +type NullCryptoKeyFeature struct { + CryptoKeyFeature CryptoKeyFeature `json:"crypto_key_feature"` + Valid bool `json:"valid"` // Valid is true if CryptoKeyFeature is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullCryptoKeyFeature) Scan(value interface{}) error { + if value == nil { + ns.CryptoKeyFeature, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.CryptoKeyFeature.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullCryptoKeyFeature) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.CryptoKeyFeature), nil +} + +func (e CryptoKeyFeature) Valid() bool { + switch e { + case CryptoKeyFeatureWorkspaceApps, + CryptoKeyFeatureOidcConvert, + CryptoKeyFeatureTailnetResume: + return true + } + return false +} + +func AllCryptoKeyFeatureValues() []CryptoKeyFeature { + return []CryptoKeyFeature{ + CryptoKeyFeatureWorkspaceApps, + CryptoKeyFeatureOidcConvert, + CryptoKeyFeatureTailnetResume, + } +} + type DisplayApp string const ( @@ -2043,6 +2104,15 @@ type AuditLog struct { ResourceIcon string `db:"resource_icon" json:"resource_icon"` } +type CryptoKey struct { + Feature CryptoKeyFeature `db:"feature" json:"feature"` + Sequence int32 `db:"sequence" json:"sequence"` + Secret sql.NullString `db:"secret" json:"secret"` + SecretKeyID sql.NullString `db:"secret_key_id" json:"secret_key_id"` + StartsAt time.Time `db:"starts_at" json:"starts_at"` + DeletesAt sql.NullTime `db:"deletes_at" json:"deletes_at"` +} + // Custom roles allow dynamic roles expanded at runtime type CustomRole struct { Name string `db:"name" json:"name"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index c8f6449be3886..efe18dfbcb8e8 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -69,6 +69,7 @@ type sqlcQuerier interface { DeleteAllTailnetTunnels(ctx context.Context, arg DeleteAllTailnetTunnelsParams) error DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error DeleteCoordinator(ctx context.Context, id uuid.UUID) error + DeleteCryptoKey(ctx context.Context, arg DeleteCryptoKeyParams) (CryptoKey, error) DeleteCustomRole(ctx context.Context, arg DeleteCustomRoleParams) error DeleteExternalAuthLink(ctx context.Context, arg DeleteExternalAuthLinkParams) error DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error @@ -131,6 +132,8 @@ type sqlcQuerier interface { // are included. GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) + GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error) + GetCryptoKeys(ctx context.Context) ([]CryptoKey, error) GetDBCryptKeys(ctx context.Context) ([]DBCryptKey, error) GetDERPMeshKey(ctx context.Context) (string, error) GetDefaultOrganization(ctx context.Context) (Organization, error) @@ -159,6 +162,7 @@ type sqlcQuerier interface { GetHungProvisionerJobs(ctx context.Context, updatedAt time.Time) ([]ProvisionerJob, error) GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg GetJFrogXrayScanByWorkspaceAndAgentIDParams) (JfrogXrayScan, error) GetLastUpdateCheck(ctx context.Context) (string, error) + GetLatestCryptoKeyByFeature(ctx context.Context, feature CryptoKeyFeature) (CryptoKey, error) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuild, error) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) @@ -338,6 +342,7 @@ type sqlcQuerier interface { // every member of the org. InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) + InsertCryptoKey(ctx context.Context, arg InsertCryptoKeyParams) (CryptoKey, error) InsertCustomRole(ctx context.Context, arg InsertCustomRoleParams) (CustomRole, error) InsertDBCryptKey(ctx context.Context, arg InsertDBCryptKeyParams) error InsertDERPMeshKey(ctx context.Context, value string) error @@ -412,6 +417,7 @@ type sqlcQuerier interface { UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error + UpdateCryptoKeyDeletesAt(ctx context.Context, arg UpdateCryptoKeyDeletesAtParams) (CryptoKey, error) UpdateCustomRole(ctx context.Context, arg UpdateCustomRoleParams) (CustomRole, error) UpdateExternalAuthLink(ctx context.Context, arg UpdateExternalAuthLinkParams) (ExternalAuthLink, error) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 173448dd93dae..ba21aaae8aa8a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -761,6 +761,186 @@ func (q *sqlQuerier) InsertAuditLog(ctx context.Context, arg InsertAuditLogParam return i, err } +const deleteCryptoKey = `-- name: DeleteCryptoKey :one +UPDATE crypto_keys +SET secret = NULL, secret_key_id = NULL +WHERE feature = $1 AND sequence = $2 RETURNING feature, sequence, secret, secret_key_id, starts_at, deletes_at +` + +type DeleteCryptoKeyParams struct { + Feature CryptoKeyFeature `db:"feature" json:"feature"` + Sequence int32 `db:"sequence" json:"sequence"` +} + +func (q *sqlQuerier) DeleteCryptoKey(ctx context.Context, arg DeleteCryptoKeyParams) (CryptoKey, error) { + row := q.db.QueryRowContext(ctx, deleteCryptoKey, arg.Feature, arg.Sequence) + var i CryptoKey + err := row.Scan( + &i.Feature, + &i.Sequence, + &i.Secret, + &i.SecretKeyID, + &i.StartsAt, + &i.DeletesAt, + ) + return i, err +} + +const getCryptoKeyByFeatureAndSequence = `-- name: GetCryptoKeyByFeatureAndSequence :one +SELECT feature, sequence, secret, secret_key_id, starts_at, deletes_at +FROM crypto_keys +WHERE feature = $1 + AND sequence = $2 + AND secret IS NOT NULL +` + +type GetCryptoKeyByFeatureAndSequenceParams struct { + Feature CryptoKeyFeature `db:"feature" json:"feature"` + Sequence int32 `db:"sequence" json:"sequence"` +} + +func (q *sqlQuerier) GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error) { + row := q.db.QueryRowContext(ctx, getCryptoKeyByFeatureAndSequence, arg.Feature, arg.Sequence) + var i CryptoKey + err := row.Scan( + &i.Feature, + &i.Sequence, + &i.Secret, + &i.SecretKeyID, + &i.StartsAt, + &i.DeletesAt, + ) + return i, err +} + +const getCryptoKeys = `-- name: GetCryptoKeys :many +SELECT feature, sequence, secret, secret_key_id, starts_at, deletes_at +FROM crypto_keys +WHERE secret IS NOT NULL +` + +func (q *sqlQuerier) GetCryptoKeys(ctx context.Context) ([]CryptoKey, error) { + rows, err := q.db.QueryContext(ctx, getCryptoKeys) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CryptoKey + for rows.Next() { + var i CryptoKey + if err := rows.Scan( + &i.Feature, + &i.Sequence, + &i.Secret, + &i.SecretKeyID, + &i.StartsAt, + &i.DeletesAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getLatestCryptoKeyByFeature = `-- name: GetLatestCryptoKeyByFeature :one +SELECT feature, sequence, secret, secret_key_id, starts_at, deletes_at +FROM crypto_keys +WHERE feature = $1 +ORDER BY sequence DESC +LIMIT 1 +` + +func (q *sqlQuerier) GetLatestCryptoKeyByFeature(ctx context.Context, feature CryptoKeyFeature) (CryptoKey, error) { + row := q.db.QueryRowContext(ctx, getLatestCryptoKeyByFeature, feature) + var i CryptoKey + err := row.Scan( + &i.Feature, + &i.Sequence, + &i.Secret, + &i.SecretKeyID, + &i.StartsAt, + &i.DeletesAt, + ) + return i, err +} + +const insertCryptoKey = `-- name: InsertCryptoKey :one +INSERT INTO crypto_keys ( + feature, + sequence, + secret, + starts_at, + secret_key_id +) VALUES ( + $1, + $2, + $3, + $4, + $5 +) RETURNING feature, sequence, secret, secret_key_id, starts_at, deletes_at +` + +type InsertCryptoKeyParams struct { + Feature CryptoKeyFeature `db:"feature" json:"feature"` + Sequence int32 `db:"sequence" json:"sequence"` + Secret sql.NullString `db:"secret" json:"secret"` + StartsAt time.Time `db:"starts_at" json:"starts_at"` + SecretKeyID sql.NullString `db:"secret_key_id" json:"secret_key_id"` +} + +func (q *sqlQuerier) InsertCryptoKey(ctx context.Context, arg InsertCryptoKeyParams) (CryptoKey, error) { + row := q.db.QueryRowContext(ctx, insertCryptoKey, + arg.Feature, + arg.Sequence, + arg.Secret, + arg.StartsAt, + arg.SecretKeyID, + ) + var i CryptoKey + err := row.Scan( + &i.Feature, + &i.Sequence, + &i.Secret, + &i.SecretKeyID, + &i.StartsAt, + &i.DeletesAt, + ) + return i, err +} + +const updateCryptoKeyDeletesAt = `-- name: UpdateCryptoKeyDeletesAt :one +UPDATE crypto_keys +SET deletes_at = $3 +WHERE feature = $1 AND sequence = $2 RETURNING feature, sequence, secret, secret_key_id, starts_at, deletes_at +` + +type UpdateCryptoKeyDeletesAtParams struct { + Feature CryptoKeyFeature `db:"feature" json:"feature"` + Sequence int32 `db:"sequence" json:"sequence"` + DeletesAt sql.NullTime `db:"deletes_at" json:"deletes_at"` +} + +func (q *sqlQuerier) UpdateCryptoKeyDeletesAt(ctx context.Context, arg UpdateCryptoKeyDeletesAtParams) (CryptoKey, error) { + row := q.db.QueryRowContext(ctx, updateCryptoKeyDeletesAt, arg.Feature, arg.Sequence, arg.DeletesAt) + var i CryptoKey + err := row.Scan( + &i.Feature, + &i.Sequence, + &i.Secret, + &i.SecretKeyID, + &i.StartsAt, + &i.DeletesAt, + ) + return i, err +} + const getDBCryptKeys = `-- name: GetDBCryptKeys :many SELECT number, active_key_digest, revoked_key_digest, created_at, revoked_at, test FROM dbcrypt_keys ORDER BY number ASC ` diff --git a/coderd/database/queries/crypto_keys.sql b/coderd/database/queries/crypto_keys.sql new file mode 100644 index 0000000000000..281e303751524 --- /dev/null +++ b/coderd/database/queries/crypto_keys.sql @@ -0,0 +1,43 @@ +-- name: GetCryptoKeys :many +SELECT * +FROM crypto_keys +WHERE secret IS NOT NULL; + +-- name: GetLatestCryptoKeyByFeature :one +SELECT * +FROM crypto_keys +WHERE feature = $1 +ORDER BY sequence DESC +LIMIT 1; + +-- name: GetCryptoKeyByFeatureAndSequence :one +SELECT * +FROM crypto_keys +WHERE feature = $1 + AND sequence = $2 + AND secret IS NOT NULL; + +-- name: DeleteCryptoKey :one +UPDATE crypto_keys +SET secret = NULL, secret_key_id = NULL +WHERE feature = $1 AND sequence = $2 RETURNING *; + +-- name: InsertCryptoKey :one +INSERT INTO crypto_keys ( + feature, + sequence, + secret, + starts_at, + secret_key_id +) VALUES ( + $1, + $2, + $3, + $4, + $5 +) RETURNING *; + +-- name: UpdateCryptoKeyDeletesAt :one +UPDATE crypto_keys +SET deletes_at = $3 +WHERE feature = $1 AND sequence = $2 RETURNING *; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index b3bf72f8178b6..01a811af9c5ed 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -9,6 +9,7 @@ const ( UniqueAgentStatsPkey UniqueConstraint = "agent_stats_pkey" // ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id); UniqueAPIKeysPkey UniqueConstraint = "api_keys_pkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); UniqueAuditLogsPkey UniqueConstraint = "audit_logs_pkey" // ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); + UniqueCryptoKeysPkey UniqueConstraint = "crypto_keys_pkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_pkey PRIMARY KEY (feature, sequence); UniqueCustomRolesUniqueKey UniqueConstraint = "custom_roles_unique_key" // ALTER TABLE ONLY custom_roles ADD CONSTRAINT custom_roles_unique_key UNIQUE (name, organization_id); UniqueDbcryptKeysActiveKeyDigestKey UniqueConstraint = "dbcrypt_keys_active_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_active_key_digest_key UNIQUE (active_key_digest); UniqueDbcryptKeysPkey UniqueConstraint = "dbcrypt_keys_pkey" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_pkey PRIMARY KEY (number); diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 596bfe3798136..efe798d4ae4ac 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -55,6 +55,16 @@ var ( Type: "audit_log", } + // ResourceCryptoKey + // Valid Actions + // - "ActionCreate" :: create crypto keys + // - "ActionDelete" :: delete crypto keys + // - "ActionRead" :: read crypto keys + // - "ActionUpdate" :: update crypto keys + ResourceCryptoKey = Object{ + Type: "crypto_key", + } + // ResourceDebugInfo // Valid Actions // - "ActionRead" :: access to debug routes @@ -299,6 +309,7 @@ func AllResources() []Objecter { ResourceAssignOrgRole, ResourceAssignRole, ResourceAuditLog, + ResourceCryptoKey, ResourceDebugInfo, ResourceDeploymentConfig, ResourceDeploymentStats, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index f8515d0a7e5f2..c553ac31cd6e3 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -274,6 +274,14 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionUpdate: actDef("update notification preferences"), }, }, + "crypto_key": { + Actions: map[Action]ActionDefinition{ + ActionRead: actDef("read crypto keys"), + ActionUpdate: actDef("update crypto keys"), + ActionDelete: actDef("delete crypto keys"), + ActionCreate: actDef("create crypto keys"), + }, + }, // idpsync_settings should always be org scoped "idpsync_settings": { Actions: map[Action]ActionDefinition{ diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 01de3ee09e34d..fb2a85a59bfe3 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -705,6 +705,15 @@ func TestRolePermissions(t *testing.T) { }, }, }, + { + Name: "CryptoKeys", + Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete, policy.ActionRead}, + Resource: rbac.ResourceCryptoKey, + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner}, + false: {setOtherOrg, setOrgNotMe, memberMe, orgMemberMe, templateAdmin, userAdmin}, + }, + }, { Name: "IDPSyncSettings", Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate}, diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 6a40784cf607f..8c3ced0946223 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -9,6 +9,7 @@ const ( ResourceAssignOrgRole RBACResource = "assign_org_role" ResourceAssignRole RBACResource = "assign_role" ResourceAuditLog RBACResource = "audit_log" + ResourceCryptoKey RBACResource = "crypto_key" ResourceDebugInfo RBACResource = "debug_info" ResourceDeploymentConfig RBACResource = "deployment_config" ResourceDeploymentStats RBACResource = "deployment_stats" @@ -62,6 +63,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceAssignOrgRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceAssignRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceAuditLog: {ActionCreate, ActionRead}, + ResourceCryptoKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceDebugInfo: {ActionRead}, ResourceDeploymentConfig: {ActionRead, ActionUpdate}, ResourceDeploymentStats: {ActionRead}, diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index f0d3e2a69df5c..517ac51807c06 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -184,6 +184,7 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | +| `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | | `resource_type` | `deployment_stats` | @@ -343,6 +344,7 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | +| `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | | `resource_type` | `deployment_stats` | @@ -502,6 +504,7 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | +| `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | | `resource_type` | `deployment_stats` | @@ -630,6 +633,7 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | +| `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | | `resource_type` | `deployment_stats` | @@ -888,6 +892,7 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | +| `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | | `resource_type` | `deployment_stats` | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 979779e70a296..917ff0287d24d 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -4354,6 +4354,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `assign_org_role` | | `assign_role` | | `audit_log` | +| `crypto_key` | | `debug_info` | | `deployment_config` | | `deployment_stats` | diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index ec56a4897a1e3..02c619cef52d5 100644 --- a/enterprise/dbcrypt/dbcrypt.go +++ b/enterprise/dbcrypt/dbcrypt.go @@ -261,6 +261,66 @@ func (db *dbCrypt) UpdateExternalAuthLink(ctx context.Context, params database.U return link, nil } +func (db *dbCrypt) GetCryptoKeys(ctx context.Context) ([]database.CryptoKey, error) { + keys, err := db.Store.GetCryptoKeys(ctx) + if err != nil { + return nil, err + } + for i := range keys { + if err := db.decryptField(&keys[i].Secret.String, keys[i].SecretKeyID); err != nil { + return nil, err + } + } + return keys, nil +} + +func (db *dbCrypt) GetLatestCryptoKeyByFeature(ctx context.Context, feature database.CryptoKeyFeature) (database.CryptoKey, error) { + key, err := db.Store.GetLatestCryptoKeyByFeature(ctx, feature) + if err != nil { + return database.CryptoKey{}, err + } + if err := db.decryptField(&key.Secret.String, key.SecretKeyID); err != nil { + return database.CryptoKey{}, err + } + return key, nil +} + +func (db *dbCrypt) GetCryptoKeyByFeatureAndSequence(ctx context.Context, params database.GetCryptoKeyByFeatureAndSequenceParams) (database.CryptoKey, error) { + key, err := db.Store.GetCryptoKeyByFeatureAndSequence(ctx, params) + if err != nil { + return database.CryptoKey{}, err + } + if err := db.decryptField(&key.Secret.String, key.SecretKeyID); err != nil { + return database.CryptoKey{}, err + } + return key, nil +} + +func (db *dbCrypt) InsertCryptoKey(ctx context.Context, params database.InsertCryptoKeyParams) (database.CryptoKey, error) { + if err := db.encryptField(¶ms.Secret.String, ¶ms.SecretKeyID); err != nil { + return database.CryptoKey{}, err + } + key, err := db.Store.InsertCryptoKey(ctx, params) + if err != nil { + return database.CryptoKey{}, err + } + if err := db.decryptField(&key.Secret.String, key.SecretKeyID); err != nil { + return database.CryptoKey{}, err + } + return key, nil +} + +func (db *dbCrypt) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { + key, err := db.Store.UpdateCryptoKeyDeletesAt(ctx, arg) + if err != nil { + return database.CryptoKey{}, err + } + if err := db.decryptField(&key.Secret.String, key.SecretKeyID); err != nil { + return database.CryptoKey{}, err + } + return key, nil +} + func (db *dbCrypt) encryptField(field *string, digest *sql.NullString) error { // If no cipher is loaded, then we can't encrypt anything! if db.ciphers == nil || db.primaryCipherDigest == "" { diff --git a/enterprise/dbcrypt/dbcrypt_internal_test.go b/enterprise/dbcrypt/dbcrypt_internal_test.go index 37fcc8cae55a3..e744317445789 100644 --- a/enterprise/dbcrypt/dbcrypt_internal_test.go +++ b/enterprise/dbcrypt/dbcrypt_internal_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "io" "testing" + "time" "github.com/lib/pq" "github.com/stretchr/testify/require" @@ -349,6 +350,129 @@ func TestExternalAuthLinks(t *testing.T) { }) } +func TestCryptoKeys(t *testing.T) { + t.Parallel() + ctx := context.Background() + + t.Run("InsertCryptoKey", func(t *testing.T) { + t.Parallel() + + db, crypt, ciphers := setup(t) + key := dbgen.CryptoKey(t, crypt, database.CryptoKey{ + Secret: sql.NullString{String: "test", Valid: true}, + }) + require.Equal(t, "test", key.Secret.String) + + key, err := db.GetCryptoKeyByFeatureAndSequence(ctx, database.GetCryptoKeyByFeatureAndSequenceParams{ + Feature: key.Feature, + Sequence: key.Sequence, + }) + require.NoError(t, err) + require.Equal(t, ciphers[0].HexDigest(), key.SecretKeyID.String) + requireEncryptedEquals(t, ciphers[0], key.Secret.String, "test") + }) + + t.Run("GetCryptoKeys", func(t *testing.T) { + t.Parallel() + db, crypt, ciphers := setup(t) + _ = dbgen.CryptoKey(t, crypt, database.CryptoKey{ + Secret: sql.NullString{String: "test", Valid: true}, + }) + keys, err := crypt.GetCryptoKeys(ctx) + require.NoError(t, err) + require.Len(t, keys, 1) + require.Equal(t, "test", keys[0].Secret.String) + require.Equal(t, ciphers[0].HexDigest(), keys[0].SecretKeyID.String) + + keys, err = db.GetCryptoKeys(ctx) + require.NoError(t, err) + require.Len(t, keys, 1) + requireEncryptedEquals(t, ciphers[0], keys[0].Secret.String, "test") + require.Equal(t, ciphers[0].HexDigest(), keys[0].SecretKeyID.String) + }) + + t.Run("GetLatestCryptoKeyByFeature", func(t *testing.T) { + t.Parallel() + db, crypt, ciphers := setup(t) + _ = dbgen.CryptoKey(t, crypt, database.CryptoKey{ + Secret: sql.NullString{String: "test", Valid: true}, + }) + key, err := crypt.GetLatestCryptoKeyByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps) + require.NoError(t, err) + require.Equal(t, "test", key.Secret.String) + require.Equal(t, ciphers[0].HexDigest(), key.SecretKeyID.String) + + key, err = db.GetLatestCryptoKeyByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps) + require.NoError(t, err) + requireEncryptedEquals(t, ciphers[0], key.Secret.String, "test") + require.Equal(t, ciphers[0].HexDigest(), key.SecretKeyID.String) + }) + + t.Run("GetCryptoKeyByFeatureAndSequence", func(t *testing.T) { + t.Parallel() + db, crypt, ciphers := setup(t) + key := dbgen.CryptoKey(t, crypt, database.CryptoKey{ + Secret: sql.NullString{String: "test", Valid: true}, + }) + key, err := crypt.GetCryptoKeyByFeatureAndSequence(ctx, database.GetCryptoKeyByFeatureAndSequenceParams{ + Feature: database.CryptoKeyFeatureWorkspaceApps, + Sequence: key.Sequence, + }) + require.NoError(t, err) + require.Equal(t, "test", key.Secret.String) + require.Equal(t, ciphers[0].HexDigest(), key.SecretKeyID.String) + + key, err = db.GetCryptoKeyByFeatureAndSequence(ctx, database.GetCryptoKeyByFeatureAndSequenceParams{ + Feature: database.CryptoKeyFeatureWorkspaceApps, + Sequence: key.Sequence, + }) + require.NoError(t, err) + requireEncryptedEquals(t, ciphers[0], key.Secret.String, "test") + require.Equal(t, ciphers[0].HexDigest(), key.SecretKeyID.String) + }) + + t.Run("UpdateCryptoKeyDeletesAt", func(t *testing.T) { + t.Parallel() + _, crypt, ciphers := setup(t) + key := dbgen.CryptoKey(t, crypt, database.CryptoKey{ + Secret: sql.NullString{String: "test", Valid: true}, + }) + key, err := crypt.UpdateCryptoKeyDeletesAt(ctx, database.UpdateCryptoKeyDeletesAtParams{ + Feature: key.Feature, + Sequence: key.Sequence, + DeletesAt: sql.NullTime{ + Time: time.Now().Add(time.Hour), + Valid: true, + }, + }) + require.NoError(t, err) + require.Equal(t, "test", key.Secret.String) + require.Equal(t, ciphers[0].HexDigest(), key.SecretKeyID.String) + }) + + t.Run("DecryptErr", func(t *testing.T) { + t.Parallel() + db, crypt, ciphers := setup(t) + key := dbgen.CryptoKey(t, db, database.CryptoKey{ + Secret: sql.NullString{ + String: fakeBase64RandomData(t, 32), + Valid: true, + }, + SecretKeyID: sql.NullString{ + String: ciphers[0].HexDigest(), + Valid: true, + }, + }) + _, err := crypt.GetCryptoKeyByFeatureAndSequence(ctx, database.GetCryptoKeyByFeatureAndSequenceParams{ + Feature: key.Feature, + Sequence: key.Sequence, + }) + require.Error(t, err, "expected an error") + var derr *DecryptFailedError + require.ErrorAs(t, err, &derr, "expected a decrypt error") + }) +} + func TestNew(t *testing.T) { t.Parallel() diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index a2a6e3d5e4161..34b2ddf021ace 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -32,6 +32,12 @@ export const RBACResourceActions: Partial< create: "create new audit log entries", read: "read audit logs", }, + crypto_key: { + create: "create crypto keys", + delete: "delete crypto keys", + read: "read crypto keys", + update: "update crypto keys", + }, debug_info: { read: "access to debug routes", }, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 7c5785cbc5c56..59059383372d7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2149,8 +2149,8 @@ export type RBACAction = "application_connect" | "assign" | "create" | "delete" export const RBACActions: RBACAction[] = ["application_connect", "assign", "create", "delete", "read", "read_personal", "ssh", "start", "stop", "update", "update_personal", "use", "view_insights"] // From codersdk/rbacresources_gen.go -export type RBACResource = "*" | "api_key" | "assign_org_role" | "assign_role" | "audit_log" | "debug_info" | "deployment_config" | "deployment_stats" | "file" | "group" | "group_member" | "idpsync_settings" | "license" | "notification_preference" | "notification_template" | "oauth2_app" | "oauth2_app_code_token" | "oauth2_app_secret" | "organization" | "organization_member" | "provisioner_daemon" | "provisioner_keys" | "replicas" | "system" | "tailnet_coordinator" | "template" | "user" | "workspace" | "workspace_dormant" | "workspace_proxy" -export const RBACResources: RBACResource[] = ["*", "api_key", "assign_org_role", "assign_role", "audit_log", "debug_info", "deployment_config", "deployment_stats", "file", "group", "group_member", "idpsync_settings", "license", "notification_preference", "notification_template", "oauth2_app", "oauth2_app_code_token", "oauth2_app_secret", "organization", "organization_member", "provisioner_daemon", "provisioner_keys", "replicas", "system", "tailnet_coordinator", "template", "user", "workspace", "workspace_dormant", "workspace_proxy"] +export type RBACResource = "*" | "api_key" | "assign_org_role" | "assign_role" | "audit_log" | "crypto_key" | "debug_info" | "deployment_config" | "deployment_stats" | "file" | "group" | "group_member" | "idpsync_settings" | "license" | "notification_preference" | "notification_template" | "oauth2_app" | "oauth2_app_code_token" | "oauth2_app_secret" | "organization" | "organization_member" | "provisioner_daemon" | "provisioner_keys" | "replicas" | "system" | "tailnet_coordinator" | "template" | "user" | "workspace" | "workspace_dormant" | "workspace_proxy" +export const RBACResources: RBACResource[] = ["*", "api_key", "assign_org_role", "assign_role", "audit_log", "crypto_key", "debug_info", "deployment_config", "deployment_stats", "file", "group", "group_member", "idpsync_settings", "license", "notification_preference", "notification_template", "oauth2_app", "oauth2_app_code_token", "oauth2_app_secret", "organization", "organization_member", "provisioner_daemon", "provisioner_keys", "replicas", "system", "tailnet_coordinator", "template", "user", "workspace", "workspace_dormant", "workspace_proxy"] // From codersdk/audit.go export type ResourceType = "api_key" | "convert_login" | "custom_role" | "git_ssh_key" | "group" | "health_settings" | "license" | "notifications_settings" | "oauth2_provider_app" | "oauth2_provider_app_secret" | "organization" | "template" | "template_version" | "user" | "workspace" | "workspace_build" | "workspace_proxy"