From 335eb05223dadbf7e47b4cfcd57f7ca6fb7616df Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Mon, 16 Sep 2024 16:02:08 -0400 Subject: [PATCH] feat: add keys to organization provision daemons (#14627) --- coderd/apidoc/docs.go | 55 +++++++++++ coderd/apidoc/swagger.json | 51 ++++++++++ coderd/coderd.go | 6 ++ coderd/database/db2sdk/db2sdk.go | 25 +++++ coderd/database/dbauthz/dbauthz.go | 4 + coderd/database/dbauthz/dbauthz_test.go | 13 +++ coderd/database/dbmem/dbmem.go | 56 +++++++++++ coderd/database/dbmetrics/dbmetrics.go | 7 ++ coderd/database/dbmock/dbmock.go | 15 +++ coderd/database/dbpurge/dbpurge_test.go | 5 + coderd/database/dump.sql | 6 +- coderd/database/foreign_key_constraint.go | 1 + ...250_built_in_psk_provisioner_keys.down.sql | 5 + ...00250_built_in_psk_provisioner_keys.up.sql | 6 ++ coderd/database/models.go | 1 + coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 68 ++++++++++++-- .../database/queries/provisionerdaemons.sql | 9 +- coderd/database/queries/provisionerkeys.sql | 17 ++++ coderd/healthcheck/provisioner.go | 20 +--- .../provisionerdserver_test.go | 1 + codersdk/provisionerdaemons.go | 44 +++++++++ docs/reference/api/debug.md | 1 + docs/reference/api/enterprise.md | 94 +++++++++++++++++++ docs/reference/api/schemas.md | 46 +++++++++ enterprise/coderd/coderd.go | 1 + enterprise/coderd/provisionerdaemons.go | 72 +++++++++----- enterprise/coderd/provisionerdaemons_test.go | 38 ++++++-- enterprise/coderd/provisionerkeys.go | 90 +++++++++++++++--- enterprise/coderd/provisionerkeys_test.go | 22 +++++ site/src/api/typesGenerated.ts | 7 ++ site/src/testHelpers/entities.ts | 13 +++ 32 files changed, 728 insertions(+), 72 deletions(-) create mode 100644 coderd/database/migrations/000250_built_in_psk_provisioner_keys.down.sql create mode 100644 coderd/database/migrations/000250_built_in_psk_provisioner_keys.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index bf199aac9f..12a7321189 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3054,6 +3054,43 @@ const docTemplate = `{ } } }, + "/organizations/{organization}/provisionerkeys/daemons": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "List provisioner key daemons", + "operationId": "list-provisioner-key-daemons", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ProvisionerKeyDaemons" + } + } + } + } + } + }, "/organizations/{organization}/provisionerkeys/{provisionerkey}": { "delete": { "security": [ @@ -11531,6 +11568,10 @@ const docTemplate = `{ "type": "string", "format": "uuid" }, + "key_id": { + "type": "string", + "format": "uuid" + }, "last_seen_at": { "type": "string", "format": "date-time" @@ -11714,6 +11755,20 @@ const docTemplate = `{ } } }, + "codersdk.ProvisionerKeyDaemons": { + "type": "object", + "properties": { + "daemons": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ProvisionerDaemon" + } + }, + "key": { + "$ref": "#/definitions/codersdk.ProvisionerKey" + } + } + }, "codersdk.ProvisionerLogLevel": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 8338dcbec6..23a1f369c5 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2682,6 +2682,39 @@ } } }, + "/organizations/{organization}/provisionerkeys/daemons": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "List provisioner key daemons", + "operationId": "list-provisioner-key-daemons", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ProvisionerKeyDaemons" + } + } + } + } + } + }, "/organizations/{organization}/provisionerkeys/{provisionerkey}": { "delete": { "security": [ @@ -10401,6 +10434,10 @@ "type": "string", "format": "uuid" }, + "key_id": { + "type": "string", + "format": "uuid" + }, "last_seen_at": { "type": "string", "format": "date-time" @@ -10576,6 +10613,20 @@ } } }, + "codersdk.ProvisionerKeyDaemons": { + "type": "object", + "properties": { + "daemons": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ProvisionerDaemon" + } + }, + "key": { + "$ref": "#/definitions/codersdk.ProvisionerKey" + } + } + }, "codersdk.ProvisionerLogLevel": { "type": "string", "enum": ["debug"], diff --git a/coderd/coderd.go b/coderd/coderd.go index de6d098c42..57015b0c7d 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1491,6 +1491,11 @@ func (api *API) CreateInMemoryTaggedProvisionerDaemon(dialCtx context.Context, n dbTypes = append(dbTypes, database.ProvisionerType(tp)) } + keyID, err := uuid.Parse(string(codersdk.ProvisionerKeyIDBuiltIn)) + if err != nil { + return nil, xerrors.Errorf("failed to parse built-in provisioner key ID: %w", err) + } + //nolint:gocritic // in-memory provisioners are owned by system daemon, err := api.Database.UpsertProvisionerDaemon(dbauthz.AsSystemRestricted(dialCtx), database.UpsertProvisionerDaemonParams{ Name: name, @@ -1501,6 +1506,7 @@ func (api *API) CreateInMemoryTaggedProvisionerDaemon(dialCtx context.Context, n LastSeenAt: sql.NullTime{Time: dbtime.Now(), Valid: true}, Version: buildinfo.Version(), APIVersion: proto.CurrentVersion.String(), + KeyID: keyID, }) if err != nil { return nil, xerrors.Errorf("failed to create in-memory provisioner daemon: %w", err) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 134b4e6cd8..a8e2c6cb93 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -533,6 +533,7 @@ func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.Provisioner Tags: dbDaemon.Tags, Version: dbDaemon.Version, APIVersion: dbDaemon.APIVersion, + KeyID: dbDaemon.KeyID, } for _, provisionerType := range dbDaemon.Provisioners { result.Provisioners = append(result.Provisioners, codersdk.ProvisionerType(provisionerType)) @@ -540,6 +541,30 @@ func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.Provisioner return result } +func RecentProvisionerDaemons(now time.Time, staleInterval time.Duration, daemons []database.ProvisionerDaemon) []codersdk.ProvisionerDaemon { + results := []codersdk.ProvisionerDaemon{} + + for _, daemon := range daemons { + // Daemon never connected, skip. + if !daemon.LastSeenAt.Valid { + continue + } + // Daemon has gone away, skip. + if now.Sub(daemon.LastSeenAt.Time) > staleInterval { + continue + } + + results = append(results, ProvisionerDaemon(daemon)) + } + + // Ensure stable order for display and for tests + sort.Slice(results, func(i, j int) bool { + return results[i].Name < results[j].Name + }) + + return results +} + func SlimRole(role rbac.Role) codersdk.SlimRole { orgID := "" if role.Identifier.OrganizationID != uuid.Nil { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 9ae60bec90..0e3b0fe134 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3066,6 +3066,10 @@ func (q *querier) ListProvisionerKeysByOrganization(ctx context.Context, organiz return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.ListProvisionerKeysByOrganization)(ctx, organizationID) } +func (q *querier) ListProvisionerKeysByOrganizationExcludeReserved(ctx context.Context, organizationID uuid.UUID) ([]database.ProvisionerKey, error) { + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.ListProvisionerKeysByOrganizationExcludeReserved)(ctx, organizationID) +} + func (q *querier) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) { workspace, err := q.db.GetWorkspaceByID(ctx, workspaceID) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index ebe4674be7..3e0c602949 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2020,6 +2020,19 @@ func (s *MethodTestSuite) TestProvisionerKeys() { } check.Args(org.ID).Asserts(pk, policy.ActionRead).Returns(pks) })) + s.Run("ListProvisionerKeysByOrganizationExcludeReserved", s.Subtest(func(db database.Store, check *expects) { + org := dbgen.Organization(s.T(), db, database.Organization{}) + pk := dbgen.ProvisionerKey(s.T(), db, database.ProvisionerKey{OrganizationID: org.ID}) + pks := []database.ProvisionerKey{ + { + ID: pk.ID, + CreatedAt: pk.CreatedAt, + OrganizationID: pk.OrganizationID, + Name: pk.Name, + }, + } + check.Args(org.ID).Asserts(pk, policy.ActionRead).Returns(pks) + })) s.Run("DeleteProvisionerKey", s.Subtest(func(db database.Store, check *expects) { org := dbgen.Organization(s.T(), db, database.Organization{}) pk := dbgen.ProvisionerKey(s.T(), db, database.ProvisionerKey{OrganizationID: org.ID}) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index d2dce3c3ba..23430a953f 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -68,6 +68,7 @@ func New() database.Store { notificationPreferences: make([]database.NotificationPreference, 0), parameterSchemas: make([]database.ParameterSchema, 0), provisionerDaemons: make([]database.ProvisionerDaemon, 0), + provisionerKeys: make([]database.ProvisionerKey, 0), workspaceAgents: make([]database.WorkspaceAgent, 0), provisionerJobLogs: make([]database.ProvisionerJobLog, 0), workspaceResources: make([]database.WorkspaceResource, 0), @@ -108,6 +109,41 @@ func New() database.Store { q.defaultProxyDisplayName = "Default" q.defaultProxyIconURL = "/emojis/1f3e1.png" + + _, err = q.InsertProvisionerKey(context.Background(), database.InsertProvisionerKeyParams{ + ID: uuid.MustParse(codersdk.ProvisionerKeyIDBuiltIn), + OrganizationID: defaultOrg.ID, + CreatedAt: dbtime.Now(), + HashedSecret: []byte{}, + Name: codersdk.ProvisionerKeyNameBuiltIn, + Tags: map[string]string{}, + }) + if err != nil { + panic(xerrors.Errorf("failed to create built-in provisioner key: %w", err)) + } + _, err = q.InsertProvisionerKey(context.Background(), database.InsertProvisionerKeyParams{ + ID: uuid.MustParse(codersdk.ProvisionerKeyIDUserAuth), + OrganizationID: defaultOrg.ID, + CreatedAt: dbtime.Now(), + HashedSecret: []byte{}, + Name: codersdk.ProvisionerKeyNameUserAuth, + Tags: map[string]string{}, + }) + if err != nil { + panic(xerrors.Errorf("failed to create user-auth provisioner key: %w", err)) + } + _, err = q.InsertProvisionerKey(context.Background(), database.InsertProvisionerKeyParams{ + ID: uuid.MustParse(codersdk.ProvisionerKeyIDPSK), + OrganizationID: defaultOrg.ID, + CreatedAt: dbtime.Now(), + HashedSecret: []byte{}, + Name: codersdk.ProvisionerKeyNamePSK, + Tags: map[string]string{}, + }) + if err != nil { + panic(xerrors.Errorf("failed to create psk provisioner key: %w", err)) + } + return q } @@ -7582,6 +7618,25 @@ func (q *FakeQuerier) ListProvisionerKeysByOrganization(_ context.Context, organ return keys, nil } +func (q *FakeQuerier) ListProvisionerKeysByOrganizationExcludeReserved(_ context.Context, organizationID uuid.UUID) ([]database.ProvisionerKey, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + keys := make([]database.ProvisionerKey, 0) + for _, key := range q.provisionerKeys { + if key.ID.String() == codersdk.ProvisionerKeyIDBuiltIn || + key.ID.String() == codersdk.ProvisionerKeyIDUserAuth || + key.ID.String() == codersdk.ProvisionerKeyIDPSK { + continue + } + if key.OrganizationID == organizationID { + keys = append(keys, key) + } + } + + return keys, nil +} + func (q *FakeQuerier) ListWorkspaceAgentPortShares(_ context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) { q.mutex.Lock() defer q.mutex.Unlock() @@ -9311,6 +9366,7 @@ func (q *FakeQuerier) UpsertProvisionerDaemon(_ context.Context, arg database.Up Version: arg.Version, APIVersion: arg.APIVersion, OrganizationID: arg.OrganizationID, + KeyID: arg.KeyID, } q.provisionerDaemons = append(q.provisionerDaemons, d) return d, nil diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 56dd9dbf09..e720a906be 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1922,6 +1922,13 @@ func (m metricsStore) ListProvisionerKeysByOrganization(ctx context.Context, org return r0, r1 } +func (m metricsStore) ListProvisionerKeysByOrganizationExcludeReserved(ctx context.Context, organizationID uuid.UUID) ([]database.ProvisionerKey, error) { + start := time.Now() + r0, r1 := m.s.ListProvisionerKeysByOrganizationExcludeReserved(ctx, organizationID) + m.queryLatencies.WithLabelValues("ListProvisionerKeysByOrganizationExcludeReserved").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) { start := time.Now() r0, r1 := m.s.ListWorkspaceAgentPortShares(ctx, workspaceID) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 634d02f7a9..018d2e9cfd 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -4045,6 +4045,21 @@ func (mr *MockStoreMockRecorder) ListProvisionerKeysByOrganization(arg0, arg1 an return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListProvisionerKeysByOrganization", reflect.TypeOf((*MockStore)(nil).ListProvisionerKeysByOrganization), arg0, arg1) } +// ListProvisionerKeysByOrganizationExcludeReserved mocks base method. +func (m *MockStore) ListProvisionerKeysByOrganizationExcludeReserved(arg0 context.Context, arg1 uuid.UUID) ([]database.ProvisionerKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListProvisionerKeysByOrganizationExcludeReserved", arg0, arg1) + ret0, _ := ret[0].([]database.ProvisionerKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListProvisionerKeysByOrganizationExcludeReserved indicates an expected call of ListProvisionerKeysByOrganizationExcludeReserved. +func (mr *MockStoreMockRecorder) ListProvisionerKeysByOrganizationExcludeReserved(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListProvisionerKeysByOrganizationExcludeReserved", reflect.TypeOf((*MockStore)(nil).ListProvisionerKeysByOrganizationExcludeReserved), arg0, arg1) +} + // ListWorkspaceAgentPortShares mocks base method. func (m *MockStore) ListWorkspaceAgentPortShares(arg0 context.Context, arg1 uuid.UUID) ([]database.WorkspaceAgentPortShare, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go index c4fdb27eb1..8353a1cbdc 100644 --- a/coderd/database/dbpurge/dbpurge_test.go +++ b/coderd/database/dbpurge/dbpurge_test.go @@ -26,6 +26,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbrollup" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionerd/proto" "github.com/coder/coder/v2/provisionersdk" "github.com/coder/coder/v2/testutil" @@ -412,6 +413,7 @@ func TestDeleteOldProvisionerDaemons(t *testing.T) { Version: "1.0.0", APIVersion: proto.CurrentVersion.String(), OrganizationID: defaultOrg.ID, + KeyID: uuid.MustParse(codersdk.ProvisionerKeyIDBuiltIn), }) require.NoError(t, err) _, err = db.UpsertProvisionerDaemon(ctx, database.UpsertProvisionerDaemonParams{ @@ -424,6 +426,7 @@ func TestDeleteOldProvisionerDaemons(t *testing.T) { Version: "1.0.0", APIVersion: proto.CurrentVersion.String(), OrganizationID: defaultOrg.ID, + KeyID: uuid.MustParse(codersdk.ProvisionerKeyIDBuiltIn), }) require.NoError(t, err) _, err = db.UpsertProvisionerDaemon(ctx, database.UpsertProvisionerDaemonParams{ @@ -438,6 +441,7 @@ func TestDeleteOldProvisionerDaemons(t *testing.T) { Version: "1.0.0", APIVersion: proto.CurrentVersion.String(), OrganizationID: defaultOrg.ID, + KeyID: uuid.MustParse(codersdk.ProvisionerKeyIDBuiltIn), }) require.NoError(t, err) _, err = db.UpsertProvisionerDaemon(ctx, database.UpsertProvisionerDaemonParams{ @@ -453,6 +457,7 @@ func TestDeleteOldProvisionerDaemons(t *testing.T) { Version: "1.0.0", APIVersion: proto.CurrentVersion.String(), OrganizationID: defaultOrg.ID, + KeyID: uuid.MustParse(codersdk.ProvisionerKeyIDBuiltIn), }) require.NoError(t, err) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 6638d52745..4bf3aab10a 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -832,7 +832,8 @@ CREATE TABLE provisioner_daemons ( last_seen_at timestamp with time zone, version text DEFAULT ''::text NOT NULL, api_version text DEFAULT '1.0'::text NOT NULL, - organization_id uuid NOT NULL + organization_id uuid NOT NULL, + key_id uuid NOT NULL ); COMMENT ON COLUMN provisioner_daemons.api_version IS 'The API version of the provisioner daemon'; @@ -2095,6 +2096,9 @@ ALTER TABLE ONLY organization_members ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; +ALTER TABLE ONLY provisioner_daemons + ADD CONSTRAINT provisioner_daemons_key_id_fkey FOREIGN KEY (key_id) REFERENCES provisioner_keys(id) ON DELETE CASCADE; + ALTER TABLE ONLY provisioner_daemons ADD CONSTRAINT provisioner_daemons_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 0c578255f0..7c9f3f55c7 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -27,6 +27,7 @@ const ( ForeignKeyOrganizationMembersOrganizationIDUUID ForeignKeyConstraint = "organization_members_organization_id_uuid_fkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_organization_id_uuid_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; ForeignKeyOrganizationMembersUserIDUUID ForeignKeyConstraint = "organization_members_user_id_uuid_fkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyParameterSchemasJobID ForeignKeyConstraint = "parameter_schemas_job_id_fkey" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; + ForeignKeyProvisionerDaemonsKeyID ForeignKeyConstraint = "provisioner_daemons_key_id_fkey" // ALTER TABLE ONLY provisioner_daemons ADD CONSTRAINT provisioner_daemons_key_id_fkey FOREIGN KEY (key_id) REFERENCES provisioner_keys(id) ON DELETE CASCADE; ForeignKeyProvisionerDaemonsOrganizationID ForeignKeyConstraint = "provisioner_daemons_organization_id_fkey" // ALTER TABLE ONLY provisioner_daemons ADD CONSTRAINT provisioner_daemons_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; ForeignKeyProvisionerJobLogsJobID ForeignKeyConstraint = "provisioner_job_logs_job_id_fkey" // ALTER TABLE ONLY provisioner_job_logs ADD CONSTRAINT provisioner_job_logs_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; ForeignKeyProvisionerJobTimingsJobID ForeignKeyConstraint = "provisioner_job_timings_job_id_fkey" // ALTER TABLE ONLY provisioner_job_timings ADD CONSTRAINT provisioner_job_timings_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000250_built_in_psk_provisioner_keys.down.sql b/coderd/database/migrations/000250_built_in_psk_provisioner_keys.down.sql new file mode 100644 index 0000000000..9d20666194 --- /dev/null +++ b/coderd/database/migrations/000250_built_in_psk_provisioner_keys.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE provisioner_daemons DROP COLUMN key_id; + +DELETE FROM provisioner_keys WHERE name = 'built-in'; +DELETE FROM provisioner_keys WHERE name = 'psk'; +DELETE FROM provisioner_keys WHERE name = 'user-auth'; diff --git a/coderd/database/migrations/000250_built_in_psk_provisioner_keys.up.sql b/coderd/database/migrations/000250_built_in_psk_provisioner_keys.up.sql new file mode 100644 index 0000000000..61660b5cf1 --- /dev/null +++ b/coderd/database/migrations/000250_built_in_psk_provisioner_keys.up.sql @@ -0,0 +1,6 @@ +INSERT INTO provisioner_keys (id, created_at, organization_id, name, hashed_secret, tags) VALUES ('00000000-0000-0000-0000-000000000001'::uuid, NOW(), (SELECT id FROM organizations WHERE is_default = true), 'built-in', ''::bytea, '{}'); +INSERT INTO provisioner_keys (id, created_at, organization_id, name, hashed_secret, tags) VALUES ('00000000-0000-0000-0000-000000000002'::uuid, NOW(), (SELECT id FROM organizations WHERE is_default = true), 'user-auth', ''::bytea, '{}'); +INSERT INTO provisioner_keys (id, created_at, organization_id, name, hashed_secret, tags) VALUES ('00000000-0000-0000-0000-000000000003'::uuid, NOW(), (SELECT id FROM organizations WHERE is_default = true), 'psk', ''::bytea, '{}'); + +ALTER TABLE provisioner_daemons ADD COLUMN key_id UUID REFERENCES provisioner_keys(id) ON DELETE CASCADE DEFAULT '00000000-0000-0000-0000-000000000001'::uuid NOT NULL; +ALTER TABLE provisioner_daemons ALTER COLUMN key_id DROP DEFAULT; diff --git a/coderd/database/models.go b/coderd/database/models.go index 9e0283ba85..4ee2f82818 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2311,6 +2311,7 @@ type ProvisionerDaemon struct { // The API version of the provisioner daemon APIVersion string `db:"api_version" json:"api_version"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + KeyID uuid.UUID `db:"key_id" json:"key_id"` } type ProvisionerJob struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index b6a1eb5e15..c8f6449be3 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -391,6 +391,7 @@ type sqlcQuerier interface { InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error) InsertWorkspaceResourceMetadata(ctx context.Context, arg InsertWorkspaceResourceMetadataParams) ([]WorkspaceResourceMetadatum, error) ListProvisionerKeysByOrganization(ctx context.Context, organizationID uuid.UUID) ([]ProvisionerKey, error) + ListProvisionerKeysByOrganizationExcludeReserved(ctx context.Context, organizationID uuid.UUID) ([]ProvisionerKey, error) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgentPortShare, error) // Arguments are optional with uuid.Nil to ignore. // - Use just 'organization_id' to get all members of an org diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 44219c4500..173448dd93 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4971,7 +4971,7 @@ func (q *sqlQuerier) DeleteOldProvisionerDaemons(ctx context.Context) error { const getProvisionerDaemons = `-- name: GetProvisionerDaemons :many SELECT - id, created_at, name, provisioners, replica_id, tags, last_seen_at, version, api_version, organization_id + id, created_at, name, provisioners, replica_id, tags, last_seen_at, version, api_version, organization_id, key_id FROM provisioner_daemons ` @@ -4996,6 +4996,7 @@ func (q *sqlQuerier) GetProvisionerDaemons(ctx context.Context) ([]ProvisionerDa &i.Version, &i.APIVersion, &i.OrganizationID, + &i.KeyID, ); err != nil { return nil, err } @@ -5012,7 +5013,7 @@ func (q *sqlQuerier) GetProvisionerDaemons(ctx context.Context) ([]ProvisionerDa const getProvisionerDaemonsByOrganization = `-- name: GetProvisionerDaemonsByOrganization :many SELECT - id, created_at, name, provisioners, replica_id, tags, last_seen_at, version, api_version, organization_id + id, created_at, name, provisioners, replica_id, tags, last_seen_at, version, api_version, organization_id, key_id FROM provisioner_daemons WHERE @@ -5039,6 +5040,7 @@ func (q *sqlQuerier) GetProvisionerDaemonsByOrganization(ctx context.Context, or &i.Version, &i.APIVersion, &i.OrganizationID, + &i.KeyID, ); err != nil { return nil, err } @@ -5084,7 +5086,8 @@ INSERT INTO last_seen_at, "version", organization_id, - api_version + api_version, + key_id ) VALUES ( gen_random_uuid(), @@ -5095,15 +5098,17 @@ VALUES ( $5, $6, $7, - $8 + $8, + $9 ) ON CONFLICT("organization_id", "name", LOWER(COALESCE(tags ->> 'owner'::text, ''::text))) DO UPDATE SET provisioners = $3, tags = $4, last_seen_at = $5, "version" = $6, api_version = $8, - organization_id = $7 -RETURNING id, created_at, name, provisioners, replica_id, tags, last_seen_at, version, api_version, organization_id + organization_id = $7, + key_id = $9 +RETURNING id, created_at, name, provisioners, replica_id, tags, last_seen_at, version, api_version, organization_id, key_id ` type UpsertProvisionerDaemonParams struct { @@ -5115,6 +5120,7 @@ type UpsertProvisionerDaemonParams struct { Version string `db:"version" json:"version"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` APIVersion string `db:"api_version" json:"api_version"` + KeyID uuid.UUID `db:"key_id" json:"key_id"` } func (q *sqlQuerier) UpsertProvisionerDaemon(ctx context.Context, arg UpsertProvisionerDaemonParams) (ProvisionerDaemon, error) { @@ -5127,6 +5133,7 @@ func (q *sqlQuerier) UpsertProvisionerDaemon(ctx context.Context, arg UpsertProv arg.Version, arg.OrganizationID, arg.APIVersion, + arg.KeyID, ) var i ProvisionerDaemon err := row.Scan( @@ -5140,6 +5147,7 @@ func (q *sqlQuerier) UpsertProvisionerDaemon(ctx context.Context, arg UpsertProv &i.Version, &i.APIVersion, &i.OrganizationID, + &i.KeyID, ) return i, err } @@ -6021,6 +6029,54 @@ func (q *sqlQuerier) ListProvisionerKeysByOrganization(ctx context.Context, orga return items, nil } +const listProvisionerKeysByOrganizationExcludeReserved = `-- name: ListProvisionerKeysByOrganizationExcludeReserved :many +SELECT + id, created_at, organization_id, name, hashed_secret, tags +FROM + provisioner_keys +WHERE + organization_id = $1 +AND + -- exclude reserved built-in key + id != '00000000-0000-0000-0000-000000000001'::uuid +AND + -- exclude reserved user-auth key + id != '00000000-0000-0000-0000-000000000002'::uuid +AND + -- exclude reserved psk key + id != '00000000-0000-0000-0000-000000000003'::uuid +` + +func (q *sqlQuerier) ListProvisionerKeysByOrganizationExcludeReserved(ctx context.Context, organizationID uuid.UUID) ([]ProvisionerKey, error) { + rows, err := q.db.QueryContext(ctx, listProvisionerKeysByOrganizationExcludeReserved, organizationID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProvisionerKey + for rows.Next() { + var i ProvisionerKey + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.OrganizationID, + &i.Name, + &i.HashedSecret, + &i.Tags, + ); 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 getWorkspaceProxies = `-- name: GetWorkspaceProxies :many SELECT id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret, region_id, derp_enabled, derp_only, version diff --git a/coderd/database/queries/provisionerdaemons.sql b/coderd/database/queries/provisionerdaemons.sql index f7c974b3fb..bee1c6e92f 100644 --- a/coderd/database/queries/provisionerdaemons.sql +++ b/coderd/database/queries/provisionerdaemons.sql @@ -33,7 +33,8 @@ INSERT INTO last_seen_at, "version", organization_id, - api_version + api_version, + key_id ) VALUES ( gen_random_uuid(), @@ -44,14 +45,16 @@ VALUES ( @last_seen_at, @version, @organization_id, - @api_version + @api_version, + @key_id ) ON CONFLICT("organization_id", "name", LOWER(COALESCE(tags ->> 'owner'::text, ''::text))) DO UPDATE SET provisioners = @provisioners, tags = @tags, last_seen_at = @last_seen_at, "version" = @version, api_version = @api_version, - organization_id = @organization_id + organization_id = @organization_id, + key_id = @key_id RETURNING *; -- name: UpdateProvisionerDaemonLastSeenAt :exec diff --git a/coderd/database/queries/provisionerkeys.sql b/coderd/database/queries/provisionerkeys.sql index cb4c763f10..3fb05a8d0f 100644 --- a/coderd/database/queries/provisionerkeys.sql +++ b/coderd/database/queries/provisionerkeys.sql @@ -37,6 +37,23 @@ WHERE AND lower(name) = lower(@name); +-- name: ListProvisionerKeysByOrganizationExcludeReserved :many +SELECT + * +FROM + provisioner_keys +WHERE + organization_id = $1 +AND + -- exclude reserved built-in key + id != '00000000-0000-0000-0000-000000000001'::uuid +AND + -- exclude reserved user-auth key + id != '00000000-0000-0000-0000-000000000002'::uuid +AND + -- exclude reserved psk key + id != '00000000-0000-0000-0000-000000000003'::uuid; + -- name: ListProvisionerKeysByOrganization :many SELECT * diff --git a/coderd/healthcheck/provisioner.go b/coderd/healthcheck/provisioner.go index b15899dc09..370a5ad04d 100644 --- a/coderd/healthcheck/provisioner.go +++ b/coderd/healthcheck/provisioner.go @@ -2,7 +2,6 @@ package healthcheck import ( "context" - "sort" "time" "golang.org/x/mod/semver" @@ -80,23 +79,10 @@ func (r *ProvisionerDaemonsReport) Run(ctx context.Context, opts *ProvisionerDae return } - // Ensure stable order for display and for tests - sort.Slice(daemons, func(i, j int) bool { - return daemons[i].Name < daemons[j].Name - }) - - for _, daemon := range daemons { - // Daemon never connected, skip. - if !daemon.LastSeenAt.Valid { - continue - } - // Daemon has gone away, skip. - if now.Sub(daemon.LastSeenAt.Time) > (opts.StaleInterval) { - continue - } - + recentDaemons := db2sdk.RecentProvisionerDaemons(now, opts.StaleInterval, daemons) + for _, daemon := range recentDaemons { it := healthsdk.ProvisionerDaemonsReportItem{ - ProvisionerDaemon: db2sdk.ProvisionerDaemon(daemon), + ProvisionerDaemon: daemon, Warnings: make([]health.Message, 0), } diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 9028af0be3..d429ae1a1b 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -1956,6 +1956,7 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi Version: buildinfo.Version(), APIVersion: proto.CurrentVersion.String(), OrganizationID: defOrg.ID, + KeyID: uuid.MustParse(codersdk.ProvisionerKeyIDBuiltIn), }) require.NoError(t, err) diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index 0dd111ea1b..77d5cba8c8 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -38,6 +38,7 @@ const ( type ProvisionerDaemon struct { ID uuid.UUID `json:"id" format:"uuid"` OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` + KeyID uuid.UUID `json:"key_id" format:"uuid"` CreatedAt time.Time `json:"created_at" format:"date-time"` LastSeenAt NullTime `json:"last_seen_at,omitempty" format:"date-time"` Name string `json:"name"` @@ -282,6 +283,31 @@ type ProvisionerKey struct { // HashedSecret - never include the access token in the API response } +type ProvisionerKeyDaemons struct { + Key ProvisionerKey `json:"key"` + Daemons []ProvisionerDaemon `json:"daemons"` +} + +const ( + ProvisionerKeyIDBuiltIn = "00000000-0000-0000-0000-000000000001" + ProvisionerKeyIDUserAuth = "00000000-0000-0000-0000-000000000002" + ProvisionerKeyIDPSK = "00000000-0000-0000-0000-000000000003" +) + +const ( + ProvisionerKeyNameBuiltIn = "built-in" + ProvisionerKeyNameUserAuth = "user-auth" + ProvisionerKeyNamePSK = "psk" +) + +func ReservedProvisionerKeyNames() []string { + return []string{ + ProvisionerKeyNameBuiltIn, + ProvisionerKeyNameUserAuth, + ProvisionerKeyNamePSK, + } +} + type CreateProvisionerKeyRequest struct { Name string `json:"name"` Tags map[string]string `json:"tags"` @@ -327,6 +353,24 @@ func (c *Client) ListProvisionerKeys(ctx context.Context, organizationID uuid.UU return resp, json.NewDecoder(res.Body).Decode(&resp) } +// ListProvisionerKeyDaemons lists all provisioner keys with their associated daemons for an organization. +func (c *Client) ListProvisionerKeyDaemons(ctx context.Context, organizationID uuid.UUID) ([]ProvisionerKeyDaemons, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/organizations/%s/provisionerkeys/daemons", organizationID.String()), + nil, + ) + if err != nil { + return nil, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var resp []ProvisionerKeyDaemons + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + // DeleteProvisionerKey deletes a provisioner key. func (c *Client) DeleteProvisionerKey(ctx context.Context, organizationID uuid.UUID, name string) error { res, err := c.Request(ctx, http.MethodDelete, diff --git a/docs/reference/api/debug.md b/docs/reference/api/debug.md index 9ca6379316..630e07510c 100644 --- a/docs/reference/api/debug.md +++ b/docs/reference/api/debug.md @@ -290,6 +290,7 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "api_version": "string", "created_at": "2019-08-24T14:15:22Z", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5", "last_seen_at": "2019-08-24T14:15:22Z", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index ea6f2d96d7..555acc4af8 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -1493,6 +1493,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/provisi "api_version": "string", "created_at": "2019-08-24T14:15:22Z", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5", "last_seen_at": "2019-08-24T14:15:22Z", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -1522,6 +1523,7 @@ Status Code **200** | `» api_version` | string | false | | | | `» created_at` | string(date-time) | false | | | | `» id` | string(uuid) | false | | | +| `» key_id` | string(uuid) | false | | | | `» last_seen_at` | string(date-time) | false | | | | `» name` | string | false | | | | `» organization_id` | string(uuid) | false | | | @@ -1655,6 +1657,98 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/provis To perform this operation, you must be authenticated. [Learn more](authentication.md). +## List provisioner key daemons + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/provisionerkeys/daemons \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /organizations/{organization}/provisionerkeys/daemons` + +### Parameters + +| Name | In | Type | Required | Description | +| -------------- | ---- | ------ | -------- | --------------- | +| `organization` | path | string | true | Organization ID | + +### Example responses + +> 200 Response + +```json +[ + { + "daemons": [ + { + "api_version": "string", + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5", + "last_seen_at": "2019-08-24T14:15:22Z", + "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "provisioners": ["string"], + "tags": { + "property1": "string", + "property2": "string" + }, + "version": "string" + } + ], + "key": { + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "organization": "452c1a86-a0af-475b-b03f-724878b0f387", + "tags": { + "property1": "string", + "property2": "string" + } + } + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.ProvisionerKeyDaemons](schemas.md#codersdkprovisionerkeydaemons) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| -------------------- | ------------------------------------------------------------ | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» daemons` | array | false | | | +| `»» api_version` | string | false | | | +| `»» created_at` | string(date-time) | false | | | +| `»» id` | string(uuid) | false | | | +| `»» key_id` | string(uuid) | false | | | +| `»» last_seen_at` | string(date-time) | false | | | +| `»» name` | string | false | | | +| `»» organization_id` | string(uuid) | false | | | +| `»» provisioners` | array | false | | | +| `»» tags` | object | false | | | +| `»»» [any property]` | string | false | | | +| `»» version` | string | false | | | +| `» key` | [codersdk.ProvisionerKey](schemas.md#codersdkprovisionerkey) | false | | | +| `»» created_at` | string(date-time) | false | | | +| `»» id` | string(uuid) | false | | | +| `»» name` | string | false | | | +| `»» organization` | string(uuid) | false | | | +| `»» tags` | object | false | | | +| `»»» [any property]` | string | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Delete provisioner key ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 99102d2051..979779e70a 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3988,6 +3988,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "api_version": "string", "created_at": "2019-08-24T14:15:22Z", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5", "last_seen_at": "2019-08-24T14:15:22Z", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -4007,6 +4008,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `api_version` | string | false | | | | `created_at` | string | false | | | | `id` | string | false | | | +| `key_id` | string | false | | | | `last_seen_at` | string | false | | | | `name` | string | false | | | | `organization_id` | string | false | | | @@ -4149,6 +4151,47 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `tags` | object | false | | | | » `[any property]` | string | false | | | +## codersdk.ProvisionerKeyDaemons + +```json +{ + "daemons": [ + { + "api_version": "string", + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5", + "last_seen_at": "2019-08-24T14:15:22Z", + "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "provisioners": ["string"], + "tags": { + "property1": "string", + "property2": "string" + }, + "version": "string" + } + ], + "key": { + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "organization": "452c1a86-a0af-475b-b03f-724878b0f387", + "tags": { + "property1": "string", + "property2": "string" + } + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| --------- | ----------------------------------------------------------------- | -------- | ------------ | ----------- | +| `daemons` | array of [codersdk.ProvisionerDaemon](#codersdkprovisionerdaemon) | false | | | +| `key` | [codersdk.ProvisionerKey](#codersdkprovisionerkey) | false | | | + ## codersdk.ProvisionerLogLevel ```json @@ -8585,6 +8628,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "api_version": "string", "created_at": "2019-08-24T14:15:22Z", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5", "last_seen_at": "2019-08-24T14:15:22Z", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -8704,6 +8748,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "api_version": "string", "created_at": "2019-08-24T14:15:22Z", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5", "last_seen_at": "2019-08-24T14:15:22Z", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -8758,6 +8803,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "api_version": "string", "created_at": "2019-08-24T14:15:22Z", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5", "last_seen_at": "2019-08-24T14:15:22Z", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 5a392360b2..ea74fd81c6 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -331,6 +331,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { ) r.Get("/", api.provisionerKeys) r.Post("/", api.postProvisionerKey) + r.Get("/daemons", api.provisionerKeyDaemons) r.Route("/{provisionerkey}", func(r chi.Router) { r.Use( httpmw.ExtractProvisionerKeyParam(options.Database), diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index 10387eaf99..7b8b30c5bf 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -82,56 +82,77 @@ type provisionerDaemonAuth struct { // authorize returns mutated tags if the given HTTP request is authorized to access the provisioner daemon // protobuf API, and returns nil, err otherwise. -func (p *provisionerDaemonAuth) authorize(r *http.Request, orgID uuid.UUID, tags map[string]string) (map[string]string, error) { +func (p *provisionerDaemonAuth) authorize(r *http.Request, orgID uuid.UUID, tags map[string]string) (uuid.UUID, map[string]string, error) { ctx := r.Context() apiKey, apiKeyOK := httpmw.APIKeyOptional(r) pk, pkOK := httpmw.ProvisionerKeyAuthOptional(r) provAuth := httpmw.ProvisionerDaemonAuthenticated(r) if !provAuth && !apiKeyOK { - return nil, xerrors.New("no API key or provisioner key provided") + return uuid.Nil, nil, xerrors.New("no API key or provisioner key provided") } if apiKeyOK && pkOK { - return nil, xerrors.New("Both API key and provisioner key authentication provided. Only one is allowed.") + return uuid.Nil, nil, xerrors.New("Both API key and provisioner key authentication provided. Only one is allowed.") } // Provisioner Key Auth if pkOK { if pk.OrganizationID != orgID { - return nil, xerrors.New("provisioner key unauthorized") + return uuid.Nil, nil, xerrors.New("provisioner key unauthorized") } if tags != nil && !maps.Equal(tags, map[string]string{}) { - return nil, xerrors.New("tags are not allowed when using a provisioner key") + return uuid.Nil, nil, xerrors.New("tags are not allowed when using a provisioner key") } // If using provisioner key / PSK auth, the daemon is, by definition, scoped to the organization. // Use the provisioner key tags here. tags = provisionersdk.MutateTags(uuid.Nil, pk.Tags) - return tags, nil + return pk.ID, tags, nil } // User Auth - tags = provisionersdk.MutateTags(apiKey.UserID, tags) - if tags[provisionersdk.TagScope] == provisionersdk.ScopeUser { - // Any authenticated user can create provisioner daemons scoped - // for jobs that they own, - return tags, nil - } - ua := httpmw.UserAuthorization(r) - err := p.authorizer.Authorize(ctx, ua, policy.ActionCreate, rbac.ResourceProvisionerDaemon.InOrg(orgID)) - if err != nil { - if !provAuth { - return nil, xerrors.New("user unauthorized") + if apiKeyOK { + userKey, err := uuid.Parse(codersdk.ProvisionerKeyIDUserAuth) + if err != nil { + return uuid.Nil, nil, xerrors.Errorf("parse user provisioner key id: %w", err) } - // Allow fallback to PSK auth if the user is not allowed to create provisioner daemons. - // This is to preserve backwards compatibility with existing user provisioner daemons. - // If using PSK auth, the daemon is, by definition, scoped to the organization. - tags = provisionersdk.MutateTags(uuid.Nil, tags) - return tags, nil + tags = provisionersdk.MutateTags(apiKey.UserID, tags) + if tags[provisionersdk.TagScope] == provisionersdk.ScopeUser { + // Any authenticated user can create provisioner daemons scoped + // for jobs that they own, + return userKey, tags, nil + } + ua := httpmw.UserAuthorization(r) + err = p.authorizer.Authorize(ctx, ua, policy.ActionCreate, rbac.ResourceProvisionerDaemon.InOrg(orgID)) + if err != nil { + if !provAuth { + return uuid.Nil, nil, xerrors.New("user unauthorized") + } + + pskKey, err := uuid.Parse(codersdk.ProvisionerKeyIDPSK) + if err != nil { + return uuid.Nil, nil, xerrors.Errorf("parse psk provisioner key id: %w", err) + } + + // Allow fallback to PSK auth if the user is not allowed to create provisioner daemons. + // This is to preserve backwards compatibility with existing user provisioner daemons. + // If using PSK auth, the daemon is, by definition, scoped to the organization. + tags = provisionersdk.MutateTags(uuid.Nil, tags) + + return pskKey, tags, nil + } + + return userKey, tags, nil } - // User is allowed to create provisioner daemons - return tags, nil + // PSK Auth + pskKey, err := uuid.Parse(codersdk.ProvisionerKeyIDPSK) + if err != nil { + return uuid.Nil, nil, xerrors.Errorf("parse psk provisioner key id: %w", err) + } + + tags = provisionersdk.MutateTags(uuid.Nil, tags) + return pskKey, tags, nil } // Serves the provisioner daemon protobuf API over a WebSocket. @@ -194,7 +215,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) api.Logger.Warn(ctx, "unnamed provisioner daemon") } - tags, err := api.provisionerDaemonAuth.authorize(r, organization.ID, tags) + keyID, tags, err := api.provisionerDaemonAuth.authorize(r, organization.ID, tags) if err != nil { api.Logger.Warn(ctx, "unauthorized provisioner daemon serve request", slog.F("tags", tags), slog.Error(err)) httpapi.Write(ctx, rw, http.StatusForbidden, @@ -267,6 +288,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) Version: versionHdrVal, APIVersion: apiVersion, OrganizationID: organization.ID, + KeyID: keyID, }) if err != nil { if !xerrors.Is(err, context.Canceled) { diff --git a/enterprise/coderd/provisionerdaemons_test.go b/enterprise/coderd/provisionerdaemons_test.go index ff87b1fd4c..e2755105c4 100644 --- a/enterprise/coderd/provisionerdaemons_test.go +++ b/enterprise/coderd/provisionerdaemons_test.go @@ -743,6 +743,7 @@ func TestGetProvisionerDaemons(t *testing.T) { Options: &coderdtest.Options{ DeploymentValues: dv, }, + ProvisionerDaemonPSK: "provisionersftw", LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ codersdk.FeatureExternalProvisionerDaemons: 1, @@ -752,6 +753,16 @@ func TestGetProvisionerDaemons(t *testing.T) { }) org := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{}) orgAdmin, _ := coderdtest.CreateAnotherUser(t, client, org.ID, rbac.ScopedRoleOrgAdmin(org.ID)) + + res, err := orgAdmin.CreateProvisionerKey(context.Background(), org.ID, codersdk.CreateProvisionerKeyRequest{ + Name: "my-key", + }) + require.NoError(t, err) + + keys, err := orgAdmin.ListProvisionerKeys(context.Background(), org.ID) + require.NoError(t, err) + require.Len(t, keys, 1) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() daemonName := testutil.MustRandString(t, 63) @@ -762,17 +773,32 @@ func TestGetProvisionerDaemons(t *testing.T) { Provisioners: []codersdk.ProvisionerType{ codersdk.ProvisionerTypeEcho, }, - Tags: map[string]string{}, + Tags: map[string]string{}, + ProvisionerKey: res.Key, }) require.NoError(t, err) srv.DRPCConn().Close() daemons, err := orgAdmin.OrganizationProvisionerDaemons(ctx, org.ID) require.NoError(t, err) - if assert.Len(t, daemons, 1) { - assert.Equal(t, daemonName, daemons[0].Name) - assert.Equal(t, buildinfo.Version(), daemons[0].Version) - assert.Equal(t, proto.CurrentVersion.String(), daemons[0].APIVersion) - } + require.Len(t, daemons, 1) + + assert.Equal(t, daemonName, daemons[0].Name) + assert.Equal(t, buildinfo.Version(), daemons[0].Version) + assert.Equal(t, proto.CurrentVersion.String(), daemons[0].APIVersion) + assert.Equal(t, keys[0].ID, daemons[0].KeyID) + + pkDaemons, err := orgAdmin.ListProvisionerKeyDaemons(ctx, org.ID) + require.NoError(t, err) + + require.Len(t, pkDaemons, 1) + require.Len(t, pkDaemons[0].Daemons, 1) + assert.Equal(t, keys[0].ID, pkDaemons[0].Key.ID) + assert.Equal(t, keys[0].Name, pkDaemons[0].Key.Name) + + assert.Equal(t, daemonName, pkDaemons[0].Daemons[0].Name) + assert.Equal(t, buildinfo.Version(), pkDaemons[0].Daemons[0].Version) + assert.Equal(t, proto.CurrentVersion.String(), pkDaemons[0].Daemons[0].APIVersion) + assert.Equal(t, keys[0].ID, pkDaemons[0].Daemons[0].KeyID) }) } diff --git a/enterprise/coderd/provisionerkeys.go b/enterprise/coderd/provisionerkeys.go index a9f003682c..993b56c3e6 100644 --- a/enterprise/coderd/provisionerkeys.go +++ b/enterprise/coderd/provisionerkeys.go @@ -3,10 +3,15 @@ package coderd import ( "fmt" "net/http" + "slices" + "strings" + "time" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/provisionerkey" "github.com/coder/coder/v2/codersdk" ) @@ -54,6 +59,21 @@ func (api *API) postProvisionerKey(rw http.ResponseWriter, r *http.Request) { return } + if slices.ContainsFunc(codersdk.ReservedProvisionerKeyNames(), func(s string) bool { + return strings.EqualFold(req.Name, s) + }) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Name cannot be reserved name '%s'", req.Name), + Validations: []codersdk.ValidationError{ + { + Field: "name", + Detail: fmt.Sprintf("Name cannot be reserved name '%s'", req.Name), + }, + }, + }) + return + } + params, token, err := provisionerkey.New(organization.ID, req.Name, req.Tags) if err != nil { httpapi.InternalServerError(rw, err) @@ -89,7 +109,7 @@ func (api *API) provisionerKeys(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() organization := httpmw.OrganizationParam(r) - pks, err := api.Database.ListProvisionerKeysByOrganization(ctx, organization.ID) + pks, err := api.Database.ListProvisionerKeysByOrganizationExcludeReserved(ctx, organization.ID) if err != nil { httpapi.InternalServerError(rw, err) return @@ -98,6 +118,54 @@ func (api *API) provisionerKeys(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, convertProvisionerKeys(pks)) } +// @Summary List provisioner key daemons +// @ID list-provisioner-key-daemons +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param organization path string true "Organization ID" +// @Success 200 {object} []codersdk.ProvisionerKeyDaemons +// @Router /organizations/{organization}/provisionerkeys/daemons [get] +func (api *API) provisionerKeyDaemons(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + organization := httpmw.OrganizationParam(r) + + pks, err := api.Database.ListProvisionerKeysByOrganization(ctx, organization.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + sdkKeys := convertProvisionerKeys(pks) + + daemons, err := api.Database.GetProvisionerDaemonsByOrganization(ctx, organization.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + // provisionerdserver.DefaultHeartbeatInterval*3 matches the healthcheck report staleInterval. + recentDaemons := db2sdk.RecentProvisionerDaemons(time.Now(), provisionerdserver.DefaultHeartbeatInterval*3, daemons) + + pkDaemons := []codersdk.ProvisionerKeyDaemons{} + for _, key := range sdkKeys { + // currently we exclude user-auth from this list + if key.ID.String() == codersdk.ProvisionerKeyIDUserAuth { + continue + } + daemons := []codersdk.ProvisionerDaemon{} + for _, daemon := range recentDaemons { + if daemon.KeyID == key.ID { + daemons = append(daemons, daemon) + } + } + pkDaemons = append(pkDaemons, codersdk.ProvisionerKeyDaemons{ + Key: key, + Daemons: daemons, + }) + } + + httpapi.Write(ctx, rw, http.StatusOK, pkDaemons) +} + // @Summary Delete provisioner key // @ID delete-provisioner-key // @Security CoderSessionToken @@ -108,24 +176,18 @@ func (api *API) provisionerKeys(rw http.ResponseWriter, r *http.Request) { // @Router /organizations/{organization}/provisionerkeys/{provisionerkey} [delete] func (api *API) deleteProvisionerKey(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - organization := httpmw.OrganizationParam(r) provisionerKey := httpmw.ProvisionerKeyParam(r) - pk, err := api.Database.GetProvisionerKeyByName(ctx, database.GetProvisionerKeyByNameParams{ - OrganizationID: organization.ID, - Name: provisionerKey.Name, - }) - if err != nil { - if httpapi.Is404Error(err) { - httpapi.ResourceNotFound(rw) - return - } - - httpapi.InternalServerError(rw, err) + if provisionerKey.ID.String() == codersdk.ProvisionerKeyIDBuiltIn || + provisionerKey.ID.String() == codersdk.ProvisionerKeyIDUserAuth || + provisionerKey.ID.String() == codersdk.ProvisionerKeyIDPSK { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Cannot delete reserved '%s' provisioner key", provisionerKey.Name), + }) return } - err = api.Database.DeleteProvisionerKey(ctx, pk.ID) + err := api.Database.DeleteProvisionerKey(ctx, provisionerKey.ID) if err != nil { httpapi.InternalServerError(rw, err) return diff --git a/enterprise/coderd/provisionerkeys_test.go b/enterprise/coderd/provisionerkeys_test.go index e4bab9b98c..84e6e590d2 100644 --- a/enterprise/coderd/provisionerkeys_test.go +++ b/enterprise/coderd/provisionerkeys_test.go @@ -64,6 +64,20 @@ func TestProvisionerKeys(t *testing.T) { err = outsideOrgAdmin.DeleteProvisionerKey(ctx, owner.OrganizationID, "key") require.ErrorContains(t, err, "Resource not found") + // org admin cannot create reserved provisioner keys + _, err = orgAdmin.CreateProvisionerKey(ctx, owner.OrganizationID, codersdk.CreateProvisionerKeyRequest{ + Name: codersdk.ProvisionerKeyNameBuiltIn, + }) + require.ErrorContains(t, err, "reserved") + _, err = orgAdmin.CreateProvisionerKey(ctx, owner.OrganizationID, codersdk.CreateProvisionerKeyRequest{ + Name: codersdk.ProvisionerKeyNameUserAuth, + }) + require.ErrorContains(t, err, "reserved") + _, err = orgAdmin.CreateProvisionerKey(ctx, owner.OrganizationID, codersdk.CreateProvisionerKeyRequest{ + Name: codersdk.ProvisionerKeyNamePSK, + }) + require.ErrorContains(t, err, "reserved") + // org admin can list provisioner keys and get an empty list keys, err := orgAdmin.ListProvisionerKeys(ctx, owner.OrganizationID) require.NoError(t, err, "org admin list provisioner keys") @@ -111,4 +125,12 @@ func TestProvisionerKeys(t *testing.T) { // org admin cannot delete a provisioner key that doesn't exist err = orgAdmin.DeleteProvisionerKey(ctx, owner.OrganizationID, "key") require.ErrorContains(t, err, "Resource not found") + + // org admin cannot delete reserved provisioner keys + err = orgAdmin.DeleteProvisionerKey(ctx, owner.OrganizationID, codersdk.ProvisionerKeyNameBuiltIn) + require.ErrorContains(t, err, "reserved") + err = orgAdmin.DeleteProvisionerKey(ctx, owner.OrganizationID, codersdk.ProvisionerKeyNameUserAuth) + require.ErrorContains(t, err, "reserved") + err = orgAdmin.DeleteProvisionerKey(ctx, owner.OrganizationID, codersdk.ProvisionerKeyNamePSK) + require.ErrorContains(t, err, "reserved") } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ba1999f287..7c5785cbc5 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1016,6 +1016,7 @@ export interface ProvisionerConfig { export interface ProvisionerDaemon { readonly id: string; readonly organization_id: string; + readonly key_id: string; readonly created_at: string; readonly last_seen_at?: string; readonly name: string; @@ -1061,6 +1062,12 @@ export interface ProvisionerKey { readonly tags: Record; } +// From codersdk/provisionerdaemons.go +export interface ProvisionerKeyDaemons { + readonly key: ProvisionerKey; + readonly daemons: Readonly>; +} + // From codersdk/workspaces.go export interface ProvisionerTiming { readonly job_id: string; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index cf6c70d0e1..a7eade4e4a 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -567,6 +567,14 @@ export const MockOrganizationMember2: TypesGen.OrganizationMemberWithUserData = roles: [], }; +export const MockProvisionerKey: TypesGen.ProvisionerKey = { + id: "test-provisioner-key", + organization: MockOrganization.id, + created_at: "2022-05-17T17:39:01.382927298Z", + name: "test-name", + tags: { scope: "organization" }, +}; + export const MockProvisioner: TypesGen.ProvisionerDaemon = { created_at: "2022-05-17T17:39:01.382927298Z", id: "test-provisioner", @@ -576,6 +584,7 @@ export const MockProvisioner: TypesGen.ProvisionerDaemon = { tags: { scope: "organization" }, version: MockBuildInfo.version, api_version: MockBuildInfo.provisioner_api_version, + key_id: MockProvisionerKey.id, }; export const MockUserProvisioner: TypesGen.ProvisionerDaemon = { @@ -3528,6 +3537,7 @@ export const MockHealth: TypesGen.HealthcheckReport = { provisioner_daemon: { id: "e455b582-ac04-4323-9ad6-ab71301fa006", organization_id: MockOrganization.id, + key_id: MockProvisionerKey.id, created_at: "2024-01-04T15:53:03.21563Z", last_seen_at: "2024-01-04T16:05:03.967551Z", name: "ok", @@ -3549,6 +3559,7 @@ export const MockHealth: TypesGen.HealthcheckReport = { provisioner_daemon: { id: "00000000-0000-0000-000000000000", organization_id: MockOrganization.id, + key_id: MockProvisionerKey.id, created_at: "2024-01-04T15:53:03.21563Z", last_seen_at: "2024-01-04T16:05:03.967551Z", name: "user-scoped", @@ -3570,6 +3581,7 @@ export const MockHealth: TypesGen.HealthcheckReport = { provisioner_daemon: { id: "e455b582-ac04-4323-9ad6-ab71301fa006", organization_id: MockOrganization.id, + key_id: MockProvisionerKey.id, created_at: "2024-01-04T15:53:03.21563Z", last_seen_at: "2024-01-04T16:05:03.967551Z", name: "unhappy", @@ -3726,6 +3738,7 @@ export const DeploymentHealthUnhealthy: TypesGen.HealthcheckReport = { provisioner_daemon: { id: "e455b582-ac04-4323-9ad6-ab71301fa006", organization_id: MockOrganization.id, + key_id: MockProvisionerKey.id, created_at: "2024-01-04T15:53:03.21563Z", last_seen_at: "2024-01-04T16:05:03.967551Z", name: "vvuurrkk-2",