diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index fa2aad745ec5a..0a8b2c07793c3 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15702,6 +15702,7 @@ const docTemplate = `{ "tailnet_coordinator", "template", "user", + "user_secret", "webpush_subscription", "workspace", "workspace_agent_devcontainers", @@ -15742,6 +15743,7 @@ const docTemplate = `{ "ResourceTailnetCoordinator", "ResourceTemplate", "ResourceUser", + "ResourceUserSecret", "ResourceWebpushSubscription", "ResourceWorkspace", "ResourceWorkspaceAgentDevcontainers", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e1bcc5bf1013c..cd6537de0e210 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14263,6 +14263,7 @@ "tailnet_coordinator", "template", "user", + "user_secret", "webpush_subscription", "workspace", "workspace_agent_devcontainers", @@ -14303,6 +14304,7 @@ "ResourceTailnetCoordinator", "ResourceTemplate", "ResourceUser", + "ResourceUserSecret", "ResourceWebpushSubscription", "ResourceWorkspace", "ResourceWorkspaceAgentDevcontainers", diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 4e752399e08eb..d5cc334f5ff7f 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1387,6 +1387,14 @@ func (q *querier) CountUnreadInboxNotificationsByUserID(ctx context.Context, use return q.db.CountUnreadInboxNotificationsByUserID(ctx, userID) } +func (q *querier) CreateUserSecret(ctx context.Context, arg database.CreateUserSecretParams) (database.UserSecret, error) { + obj := rbac.ResourceUserSecret.WithOwner(arg.UserID.String()) + if err := q.authorizeContext(ctx, policy.ActionCreate, obj); err != nil { + return database.UserSecret{}, err + } + return q.db.CreateUserSecret(ctx, arg) +} + // TODO: Handle org scoped lookups func (q *querier) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) { roleObject := rbac.ResourceAssignRole @@ -1657,6 +1665,19 @@ func (q *querier) DeleteTailnetTunnel(ctx context.Context, arg database.DeleteTa return q.db.DeleteTailnetTunnel(ctx, arg) } +func (q *querier) DeleteUserSecret(ctx context.Context, id uuid.UUID) error { + // First get the secret to check ownership + secret, err := q.GetUserSecret(ctx, id) + if err != nil { + return err + } + + if err := q.authorizeContext(ctx, policy.ActionDelete, secret); err != nil { + return err + } + return q.db.DeleteUserSecret(ctx, id) +} + func (q *querier) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceWebpushSubscription.WithOwner(arg.UserID.String())); err != nil { return err @@ -3075,6 +3096,28 @@ func (q *querier) GetUserNotificationPreferences(ctx context.Context, userID uui return q.db.GetUserNotificationPreferences(ctx, userID) } +func (q *querier) GetUserSecret(ctx context.Context, id uuid.UUID) (database.UserSecret, error) { + // First get the secret to check ownership + secret, err := q.db.GetUserSecret(ctx, id) + if err != nil { + return database.UserSecret{}, err + } + + if err := q.authorizeContext(ctx, policy.ActionRead, secret); err != nil { + return database.UserSecret{}, err + } + return secret, nil +} + +func (q *querier) GetUserSecretByUserIDAndName(ctx context.Context, arg database.GetUserSecretByUserIDAndNameParams) (database.UserSecret, error) { + obj := rbac.ResourceUserSecret.WithOwner(arg.UserID.String()) + if err := q.authorizeContext(ctx, policy.ActionRead, obj); err != nil { + return database.UserSecret{}, err + } + + return q.db.GetUserSecretByUserIDAndName(ctx, arg) +} + func (q *querier) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil { return nil, err @@ -4153,6 +4196,14 @@ func (q *querier) ListProvisionerKeysByOrganizationExcludeReserved(ctx context.C return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.ListProvisionerKeysByOrganizationExcludeReserved)(ctx, organizationID) } +func (q *querier) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]database.UserSecret, error) { + obj := rbac.ResourceUserSecret.WithOwner(userID.String()) + if err := q.authorizeContext(ctx, policy.ActionRead, obj); err != nil { + return nil, err + } + return q.db.ListUserSecrets(ctx, userID) +} + func (q *querier) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) { workspace, err := q.db.GetWorkspaceByID(ctx, workspaceID) if err != nil { @@ -4866,6 +4917,19 @@ func (q *querier) UpdateUserRoles(ctx context.Context, arg database.UpdateUserRo return q.db.UpdateUserRoles(ctx, arg) } +func (q *querier) UpdateUserSecret(ctx context.Context, arg database.UpdateUserSecretParams) (database.UserSecret, error) { + // First get the secret to check ownership + secret, err := q.db.GetUserSecret(ctx, arg.ID) + if err != nil { + return database.UserSecret{}, err + } + + if err := q.authorizeContext(ctx, policy.ActionUpdate, secret); err != nil { + return database.UserSecret{}, err + } + return q.db.UpdateUserSecret(ctx, arg) +} + func (q *querier) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) { fetch := func(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) { return q.db.GetUserByID(ctx, arg.ID) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index deca01456244f..82b7b47c892b2 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -5883,3 +5883,64 @@ func (s *MethodTestSuite) TestAuthorizePrebuiltWorkspace() { }).Asserts(w, policy.ActionUpdate, w.AsPrebuild(), policy.ActionUpdate) })) } + +func (s *MethodTestSuite) TestUserSecrets() { + s.Run("GetUserSecretByUserIDAndName", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + userSecret := dbgen.UserSecret(s.T(), db, database.UserSecret{ + UserID: user.ID, + }) + arg := database.GetUserSecretByUserIDAndNameParams{ + UserID: user.ID, + Name: userSecret.Name, + } + check.Args(arg). + Asserts(rbac.ResourceUserSecret.WithOwner(arg.UserID.String()), policy.ActionRead). + Returns(userSecret) + })) + s.Run("GetUserSecret", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + userSecret := dbgen.UserSecret(s.T(), db, database.UserSecret{ + UserID: user.ID, + }) + check.Args(userSecret.ID). + Asserts(userSecret, policy.ActionRead). + Returns(userSecret) + })) + s.Run("ListUserSecrets", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + userSecret := dbgen.UserSecret(s.T(), db, database.UserSecret{ + UserID: user.ID, + }) + check.Args(user.ID). + Asserts(rbac.ResourceUserSecret.WithOwner(user.ID.String()), policy.ActionRead). + Returns([]database.UserSecret{userSecret}) + })) + s.Run("CreateUserSecret", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + arg := database.CreateUserSecretParams{ + UserID: user.ID, + } + check.Args(arg). + Asserts(rbac.ResourceUserSecret.WithOwner(arg.UserID.String()), policy.ActionCreate) + })) + s.Run("UpdateUserSecret", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + userSecret := dbgen.UserSecret(s.T(), db, database.UserSecret{ + UserID: user.ID, + }) + arg := database.UpdateUserSecretParams{ + ID: userSecret.ID, + } + check.Args(arg). + Asserts(userSecret, policy.ActionUpdate) + })) + s.Run("DeleteUserSecret", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + userSecret := dbgen.UserSecret(s.T(), db, database.UserSecret{ + UserID: user.ID, + }) + check.Args(userSecret.ID). + Asserts(userSecret, policy.ActionRead, userSecret, policy.ActionDelete) + })) +} diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 81d9efd1cd3e3..11e02d0f651e9 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -1422,6 +1422,20 @@ func PresetParameter(t testing.TB, db database.Store, seed database.InsertPreset return parameters } +func UserSecret(t testing.TB, db database.Store, seed database.UserSecret) database.UserSecret { + userSecret, err := db.CreateUserSecret(genCtx, database.CreateUserSecretParams{ + ID: takeFirst(seed.ID, uuid.New()), + UserID: takeFirst(seed.UserID, uuid.New()), + Name: takeFirst(seed.Name, "secret-name"), + Description: takeFirst(seed.Description, "secret description"), + Value: takeFirst(seed.Value, "secret value"), + EnvName: takeFirst(seed.EnvName, "SECRET_ENV_NAME"), + FilePath: takeFirst(seed.FilePath, "~/secret/file/path"), + }) + require.NoError(t, err, "failed to insert user secret") + return userSecret +} + func ClaimPrebuild(t testing.TB, db database.Store, newUserID uuid.UUID, newName string, presetID uuid.UUID) database.ClaimPrebuiltWorkspaceRow { claimedWorkspace, err := db.ClaimPrebuiltWorkspace(genCtx, database.ClaimPrebuiltWorkspaceParams{ NewUserID: newUserID, diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index bbed6b55346c8..e0606f9e40665 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -215,6 +215,13 @@ func (m queryMetricsStore) CountUnreadInboxNotificationsByUserID(ctx context.Con return r0, r1 } +func (m queryMetricsStore) CreateUserSecret(ctx context.Context, arg database.CreateUserSecretParams) (database.UserSecret, error) { + start := time.Now() + r0, r1 := m.s.CreateUserSecret(ctx, arg) + m.queryLatencies.WithLabelValues("CreateUserSecret").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) { start := time.Now() r0, r1 := m.s.CustomRoles(ctx, arg) @@ -460,6 +467,13 @@ func (m queryMetricsStore) DeleteTailnetTunnel(ctx context.Context, arg database return r0, r1 } +func (m queryMetricsStore) DeleteUserSecret(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteUserSecret(ctx, id) + m.queryLatencies.WithLabelValues("DeleteUserSecret").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error { start := time.Now() r0 := m.s.DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx, arg) @@ -1657,6 +1671,20 @@ func (m queryMetricsStore) GetUserNotificationPreferences(ctx context.Context, u return r0, r1 } +func (m queryMetricsStore) GetUserSecret(ctx context.Context, id uuid.UUID) (database.UserSecret, error) { + start := time.Now() + r0, r1 := m.s.GetUserSecret(ctx, id) + m.queryLatencies.WithLabelValues("GetUserSecret").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m queryMetricsStore) GetUserSecretByUserIDAndName(ctx context.Context, arg database.GetUserSecretByUserIDAndNameParams) (database.UserSecret, error) { + start := time.Now() + r0, r1 := m.s.GetUserSecretByUserIDAndName(ctx, arg) + m.queryLatencies.WithLabelValues("GetUserSecretByUserIDAndName").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) { start := time.Now() r0, r1 := m.s.GetUserStatusCounts(ctx, arg) @@ -2539,6 +2567,13 @@ func (m queryMetricsStore) ListProvisionerKeysByOrganizationExcludeReserved(ctx return r0, r1 } +func (m queryMetricsStore) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]database.UserSecret, error) { + start := time.Now() + r0, r1 := m.s.ListUserSecrets(ctx, userID) + m.queryLatencies.WithLabelValues("ListUserSecrets").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) { start := time.Now() r0, r1 := m.s.ListWorkspaceAgentPortShares(ctx, workspaceID) @@ -2987,6 +3022,13 @@ func (m queryMetricsStore) UpdateUserRoles(ctx context.Context, arg database.Upd return user, err } +func (m queryMetricsStore) UpdateUserSecret(ctx context.Context, arg database.UpdateUserSecretParams) (database.UserSecret, error) { + start := time.Now() + r0, r1 := m.s.UpdateUserSecret(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateUserSecret").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) { start := time.Now() user, err := m.s.UpdateUserStatus(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index e1d40f12eb521..22807f0e3569d 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -338,6 +338,21 @@ func (mr *MockStoreMockRecorder) CountUnreadInboxNotificationsByUserID(ctx, user return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountUnreadInboxNotificationsByUserID", reflect.TypeOf((*MockStore)(nil).CountUnreadInboxNotificationsByUserID), ctx, userID) } +// CreateUserSecret mocks base method. +func (m *MockStore) CreateUserSecret(ctx context.Context, arg database.CreateUserSecretParams) (database.UserSecret, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateUserSecret", ctx, arg) + ret0, _ := ret[0].(database.UserSecret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateUserSecret indicates an expected call of CreateUserSecret. +func (mr *MockStoreMockRecorder) CreateUserSecret(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUserSecret", reflect.TypeOf((*MockStore)(nil).CreateUserSecret), ctx, arg) +} + // CustomRoles mocks base method. func (m *MockStore) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) { m.ctrl.T.Helper() @@ -835,6 +850,20 @@ func (mr *MockStoreMockRecorder) DeleteTailnetTunnel(ctx, arg any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTailnetTunnel", reflect.TypeOf((*MockStore)(nil).DeleteTailnetTunnel), ctx, arg) } +// DeleteUserSecret mocks base method. +func (m *MockStore) DeleteUserSecret(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUserSecret", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteUserSecret indicates an expected call of DeleteUserSecret. +func (mr *MockStoreMockRecorder) DeleteUserSecret(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserSecret", reflect.TypeOf((*MockStore)(nil).DeleteUserSecret), ctx, id) +} + // DeleteWebpushSubscriptionByUserIDAndEndpoint mocks base method. func (m *MockStore) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error { m.ctrl.T.Helper() @@ -3527,6 +3556,36 @@ func (mr *MockStoreMockRecorder) GetUserNotificationPreferences(ctx, userID any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserNotificationPreferences", reflect.TypeOf((*MockStore)(nil).GetUserNotificationPreferences), ctx, userID) } +// GetUserSecret mocks base method. +func (m *MockStore) GetUserSecret(ctx context.Context, id uuid.UUID) (database.UserSecret, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserSecret", ctx, id) + ret0, _ := ret[0].(database.UserSecret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserSecret indicates an expected call of GetUserSecret. +func (mr *MockStoreMockRecorder) GetUserSecret(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserSecret", reflect.TypeOf((*MockStore)(nil).GetUserSecret), ctx, id) +} + +// GetUserSecretByUserIDAndName mocks base method. +func (m *MockStore) GetUserSecretByUserIDAndName(ctx context.Context, arg database.GetUserSecretByUserIDAndNameParams) (database.UserSecret, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserSecretByUserIDAndName", ctx, arg) + ret0, _ := ret[0].(database.UserSecret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserSecretByUserIDAndName indicates an expected call of GetUserSecretByUserIDAndName. +func (mr *MockStoreMockRecorder) GetUserSecretByUserIDAndName(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserSecretByUserIDAndName", reflect.TypeOf((*MockStore)(nil).GetUserSecretByUserIDAndName), ctx, arg) +} + // GetUserStatusCounts mocks base method. func (m *MockStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) { m.ctrl.T.Helper() @@ -5417,6 +5476,21 @@ func (mr *MockStoreMockRecorder) ListProvisionerKeysByOrganizationExcludeReserve return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListProvisionerKeysByOrganizationExcludeReserved", reflect.TypeOf((*MockStore)(nil).ListProvisionerKeysByOrganizationExcludeReserved), ctx, organizationID) } +// ListUserSecrets mocks base method. +func (m *MockStore) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]database.UserSecret, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListUserSecrets", ctx, userID) + ret0, _ := ret[0].([]database.UserSecret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListUserSecrets indicates an expected call of ListUserSecrets. +func (mr *MockStoreMockRecorder) ListUserSecrets(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserSecrets", reflect.TypeOf((*MockStore)(nil).ListUserSecrets), ctx, userID) +} + // ListWorkspaceAgentPortShares mocks base method. func (m *MockStore) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) { m.ctrl.T.Helper() @@ -6372,6 +6446,21 @@ func (mr *MockStoreMockRecorder) UpdateUserRoles(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserRoles", reflect.TypeOf((*MockStore)(nil).UpdateUserRoles), ctx, arg) } +// UpdateUserSecret mocks base method. +func (m *MockStore) UpdateUserSecret(ctx context.Context, arg database.UpdateUserSecretParams) (database.UserSecret, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserSecret", ctx, arg) + ret0, _ := ret[0].(database.UserSecret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserSecret indicates an expected call of UpdateUserSecret. +func (mr *MockStoreMockRecorder) UpdateUserSecret(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserSecret", reflect.TypeOf((*MockStore)(nil).UpdateUserSecret), ctx, arg) +} + // UpdateUserStatus mocks base method. func (m *MockStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 5245920ba04a9..7bea770248310 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1861,6 +1861,18 @@ COMMENT ON COLUMN user_links.oauth_refresh_token_key_id IS 'The ID of the key us COMMENT ON COLUMN user_links.claims IS 'Claims from the IDP for the linked user. Includes both id_token and userinfo claims. '; +CREATE TABLE user_secrets ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + user_id uuid NOT NULL, + name text NOT NULL, + description text NOT NULL, + value text NOT NULL, + env_name text DEFAULT ''::text NOT NULL, + file_path text DEFAULT ''::text NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + CREATE TABLE user_status_changes ( id uuid DEFAULT gen_random_uuid() NOT NULL, user_id uuid NOT NULL, @@ -2675,6 +2687,9 @@ ALTER TABLE ONLY user_deleted ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); +ALTER TABLE ONLY user_secrets + ADD CONSTRAINT user_secrets_pkey PRIMARY KEY (id); + ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id); @@ -2863,6 +2878,12 @@ CREATE UNIQUE INDEX templates_organization_id_name_idx ON templates USING btree CREATE UNIQUE INDEX user_links_linked_id_login_type_idx ON user_links USING btree (linked_id, login_type) WHERE (linked_id <> ''::text); +CREATE UNIQUE INDEX user_secrets_user_env_name_idx ON user_secrets USING btree (user_id, env_name) WHERE (env_name <> ''::text); + +CREATE UNIQUE INDEX user_secrets_user_file_path_idx ON user_secrets USING btree (user_id, file_path) WHERE (file_path <> ''::text); + +CREATE UNIQUE INDEX user_secrets_user_name_idx ON user_secrets USING btree (user_id, name); + CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE (deleted = false); CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false); @@ -3168,6 +3189,9 @@ ALTER TABLE ONLY user_links ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY user_secrets + ADD CONSTRAINT user_secrets_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index c3aaf7342a97c..33aa8edd69032 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -63,6 +63,7 @@ const ( ForeignKeyUserLinksOauthAccessTokenKeyID ForeignKeyConstraint = "user_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyUserLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "user_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyUserLinksUserID ForeignKeyConstraint = "user_links_user_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyUserSecretsUserID ForeignKeyConstraint = "user_secrets_user_id_fkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyUserStatusChangesUserID ForeignKeyConstraint = "user_status_changes_user_id_fkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); ForeignKeyWebpushSubscriptionsUserID ForeignKeyConstraint = "webpush_subscriptions_user_id_fkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentDevcontainersWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_devcontainers_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000357_add_user_secrets.down.sql b/coderd/database/migrations/000357_add_user_secrets.down.sql new file mode 100644 index 0000000000000..67bd30002e23a --- /dev/null +++ b/coderd/database/migrations/000357_add_user_secrets.down.sql @@ -0,0 +1,7 @@ +-- Drop the unique indexes first (in reverse order of creation) +DROP INDEX IF EXISTS user_secrets_user_file_path_idx; +DROP INDEX IF EXISTS user_secrets_user_env_name_idx; +DROP INDEX IF EXISTS user_secrets_user_name_idx; + +-- Drop the table +DROP TABLE IF EXISTS user_secrets; diff --git a/coderd/database/migrations/000357_add_user_secrets.up.sql b/coderd/database/migrations/000357_add_user_secrets.up.sql new file mode 100644 index 0000000000000..8a4d398f490eb --- /dev/null +++ b/coderd/database/migrations/000357_add_user_secrets.up.sql @@ -0,0 +1,34 @@ +-- Stores encrypted user secrets (global, available across all organizations) +CREATE TABLE user_secrets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL, + + -- The encrypted secret value (base64-encoded encrypted data) + value TEXT NOT NULL, + + -- Auto-injection settings + -- Environment variable name (e.g., "DATABASE_PASSWORD", "API_KEY") + -- Empty string means don't inject as env var + env_name TEXT NOT NULL DEFAULT '', + + -- File path where secret should be written (e.g., "/home/coder/.ssh/id_rsa") + -- Empty string means don't inject as file + file_path TEXT NOT NULL DEFAULT '', + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +-- Unique constraint: user can't have duplicate secret names +CREATE UNIQUE INDEX user_secrets_user_name_idx ON user_secrets(user_id, name); + +-- Unique constraint: user can't have duplicate env names +CREATE UNIQUE INDEX user_secrets_user_env_name_idx ON user_secrets(user_id, env_name) +WHERE env_name != ''; + +-- Unique constraint: user can't have duplicate file paths +CREATE UNIQUE INDEX user_secrets_user_file_path_idx ON user_secrets(user_id, file_path) +WHERE file_path != ''; diff --git a/coderd/database/migrations/testdata/fixtures/000357_add_user_secrets.up.sql b/coderd/database/migrations/testdata/fixtures/000357_add_user_secrets.up.sql new file mode 100644 index 0000000000000..a82ceb593b629 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000357_add_user_secrets.up.sql @@ -0,0 +1,18 @@ +INSERT INTO user_secrets ( + id, + user_id, + name, + description, + value, + env_name, + file_path +) +VALUES ( + '4848b19e-b392-4a1b-bc7d-0b7ffb41ef87', + '30095c71-380b-457a-8995-97b8ee6e5307', + 'secret-name', + 'secret description', + 'secret value', + 'SECRET_ENV_NAME', + '~/secret/file/path' +); diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index caf7ccce4c6a7..e080c7d7e4217 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -632,3 +632,7 @@ func (m WorkspaceAgentVolumeResourceMonitor) Debounce( return m.DebouncedUntil, false } + +func (s UserSecret) RBACObject() rbac.Object { + return rbac.ResourceUserSecret.WithID(s.ID).WithOwner(s.UserID.String()) +} diff --git a/coderd/database/models.go b/coderd/database/models.go index 8b13c8a8af057..75d2b941dab3c 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3812,6 +3812,18 @@ type UserLink struct { Claims UserLinkClaims `db:"claims" json:"claims"` } +type UserSecret struct { + ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` + Description string `db:"description" json:"description"` + Value string `db:"value" json:"value"` + EnvName string `db:"env_name" json:"env_name"` + FilePath string `db:"file_path" json:"file_path"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + // Tracks the history of user status changes type UserStatusChange struct { ID uuid.UUID `db:"id" json:"id"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 1ea4ae5376f80..a0f265e9658ce 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -71,6 +71,7 @@ type sqlcQuerier interface { // Prebuild considered in-progress if it's in the "starting", "stopping", or "deleting" state. CountInProgressPrebuilds(ctx context.Context) ([]CountInProgressPrebuildsRow, error) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) + CreateUserSecret(ctx context.Context, arg CreateUserSecretParams) (UserSecret, error) CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error) DeleteAPIKeyByID(ctx context.Context, id string) error DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error @@ -118,6 +119,7 @@ type sqlcQuerier interface { DeleteTailnetClientSubscription(ctx context.Context, arg DeleteTailnetClientSubscriptionParams) error DeleteTailnetPeer(ctx context.Context, arg DeleteTailnetPeerParams) (DeleteTailnetPeerRow, error) DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error) + DeleteUserSecret(ctx context.Context, id uuid.UUID) error DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error DeleteWorkspaceAgentPortShare(ctx context.Context, arg DeleteWorkspaceAgentPortShareParams) error @@ -383,6 +385,8 @@ type sqlcQuerier interface { GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]UserLink, error) GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]NotificationPreference, error) + GetUserSecret(ctx context.Context, id uuid.UUID) (UserSecret, error) + GetUserSecretByUserIDAndName(ctx context.Context, arg GetUserSecretByUserIDAndNameParams) (UserSecret, error) // GetUserStatusCounts returns the count of users in each status over time. // The time range is inclusively defined by the start_time and end_time parameters. // @@ -546,6 +550,7 @@ type sqlcQuerier interface { 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) + ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]UserSecret, error) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgentPortShare, error) MarkAllInboxNotificationsAsRead(ctx context.Context, arg MarkAllInboxNotificationsAsReadParams) error OIDCClaimFieldValues(ctx context.Context, arg OIDCClaimFieldValuesParams) ([]string, error) @@ -621,6 +626,7 @@ type sqlcQuerier interface { UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error) + UpdateUserSecret(ctx context.Context, arg UpdateUserSecretParams) (UserSecret, error) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error) UpdateUserTerminalFont(ctx context.Context, arg UpdateUserTerminalFontParams) (UserConfig, error) UpdateUserThemePreference(ctx context.Context, arg UpdateUserThemePreferenceParams) (UserConfig, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index d90967b95a384..c964a066c58eb 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -6004,6 +6004,252 @@ func TestGetRunningPrebuiltWorkspaces(t *testing.T) { require.Equal(t, runningPrebuild.ID, runningPrebuilds[0].ID, "expected the running prebuilt workspace to be returned") } +func TestUserSecretsCRUDOperations(t *testing.T) { + t.Parallel() + + // Use raw database without dbauthz wrapper for this test + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitMedium) + + t.Run("FullCRUDWorkflow", func(t *testing.T) { + t.Parallel() + + // Create a new user for this test + testUser := dbgen.User(t, db, database.User{}) + + // 1. CREATE + secretID := uuid.New() + createParams := database.CreateUserSecretParams{ + ID: secretID, + UserID: testUser.ID, + Name: "workflow-secret", + Description: "Secret for full CRUD workflow", + Value: "workflow-value", + EnvName: "WORKFLOW_ENV", + FilePath: "/workflow/path", + } + + createdSecret, err := db.CreateUserSecret(ctx, createParams) + require.NoError(t, err) + assert.Equal(t, secretID, createdSecret.ID) + + // 2. READ by ID + readSecret, err := db.GetUserSecret(ctx, createdSecret.ID) + require.NoError(t, err) + assert.Equal(t, createdSecret.ID, readSecret.ID) + assert.Equal(t, "workflow-secret", readSecret.Name) + + // 3. READ by UserID and Name + readByNameParams := database.GetUserSecretByUserIDAndNameParams{ + UserID: testUser.ID, + Name: "workflow-secret", + } + readByNameSecret, err := db.GetUserSecretByUserIDAndName(ctx, readByNameParams) + require.NoError(t, err) + assert.Equal(t, createdSecret.ID, readByNameSecret.ID) + + // 4. LIST + secrets, err := db.ListUserSecrets(ctx, testUser.ID) + require.NoError(t, err) + require.Len(t, secrets, 1) + assert.Equal(t, createdSecret.ID, secrets[0].ID) + + // 5. UPDATE + updateParams := database.UpdateUserSecretParams{ + ID: createdSecret.ID, + Description: "Updated workflow description", + Value: "updated-workflow-value", + EnvName: "UPDATED_WORKFLOW_ENV", + FilePath: "/updated/workflow/path", + } + + updatedSecret, err := db.UpdateUserSecret(ctx, updateParams) + require.NoError(t, err) + assert.Equal(t, "Updated workflow description", updatedSecret.Description) + assert.Equal(t, "updated-workflow-value", updatedSecret.Value) + + // 6. DELETE + err = db.DeleteUserSecret(ctx, createdSecret.ID) + require.NoError(t, err) + + // Verify deletion + _, err = db.GetUserSecret(ctx, createdSecret.ID) + require.Error(t, err) + assert.Contains(t, err.Error(), "no rows in result set") + + // Verify list is empty + secrets, err = db.ListUserSecrets(ctx, testUser.ID) + require.NoError(t, err) + assert.Len(t, secrets, 0) + }) + + t.Run("UniqueConstraints", func(t *testing.T) { + t.Parallel() + + // Create a new user for this test + testUser := dbgen.User(t, db, database.User{}) + + // Create first secret + secret1 := dbgen.UserSecret(t, db, database.UserSecret{ + UserID: testUser.ID, + Name: "unique-test", + Description: "First secret", + Value: "value1", + EnvName: "UNIQUE_ENV", + FilePath: "/unique/path", + }) + + // Try to create another secret with the same name (should fail) + _, err := db.CreateUserSecret(ctx, database.CreateUserSecretParams{ + UserID: testUser.ID, + Name: "unique-test", // Same name + Description: "Second secret", + Value: "value2", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate key value") + + // Try to create another secret with the same env_name (should fail) + _, err = db.CreateUserSecret(ctx, database.CreateUserSecretParams{ + UserID: testUser.ID, + Name: "unique-test-2", + Description: "Second secret", + Value: "value2", + EnvName: "UNIQUE_ENV", // Same env_name + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate key value") + + // Try to create another secret with the same file_path (should fail) + _, err = db.CreateUserSecret(ctx, database.CreateUserSecretParams{ + UserID: testUser.ID, + Name: "unique-test-3", + Description: "Second secret", + Value: "value2", + FilePath: "/unique/path", // Same file_path + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate key value") + + // Create secret with empty env_name and file_path (should succeed) + secret2 := dbgen.UserSecret(t, db, database.UserSecret{ + UserID: testUser.ID, + Name: "unique-test-4", + Description: "Second secret", + Value: "value2", + EnvName: "", // Empty env_name + FilePath: "", // Empty file_path + }) + + // Verify both secrets exist + _, err = db.GetUserSecret(ctx, secret1.ID) + require.NoError(t, err) + _, err = db.GetUserSecret(ctx, secret2.ID) + require.NoError(t, err) + }) +} + +func TestUserSecretsAuthorization(t *testing.T) { + t.Parallel() + + // Use raw database and wrap with dbauthz for authorization testing + db, _ := dbtestutil.NewDB(t) + authorizer := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()) + authDB := dbauthz.New(db, authorizer, slogtest.Make(t, &slogtest.Options{}), coderdtest.AccessControlStorePointer()) + ctx := testutil.Context(t, testutil.WaitMedium) + + // Create test users + user1 := dbgen.User(t, db, database.User{}) + user2 := dbgen.User(t, db, database.User{}) + owner := dbgen.User(t, db, database.User{}) + orgAdmin := dbgen.User(t, db, database.User{}) + + // Create organization for org-scoped roles + org := dbgen.Organization(t, db, database.Organization{}) + + // Create secrets for users + user1Secret := dbgen.UserSecret(t, db, database.UserSecret{ + UserID: user1.ID, + Name: "user1-secret", + Description: "User 1's secret", + Value: "user1-value", + }) + + user2Secret := dbgen.UserSecret(t, db, database.UserSecret{ + UserID: user2.ID, + Name: "user2-secret", + Description: "User 2's secret", + Value: "user2-value", + }) + + testCases := []struct { + name string + subject rbac.Subject + secretID uuid.UUID + expectedAccess bool + }{ + { + name: "UserCanAccessOwnSecrets", + subject: rbac.Subject{ + ID: user1.ID.String(), + Roles: rbac.RoleIdentifiers{rbac.RoleMember()}, + Scope: rbac.ScopeAll, + }, + secretID: user1Secret.ID, + expectedAccess: true, + }, + { + name: "UserCannotAccessOtherUserSecrets", + subject: rbac.Subject{ + ID: user1.ID.String(), + Roles: rbac.RoleIdentifiers{rbac.RoleMember()}, + Scope: rbac.ScopeAll, + }, + secretID: user2Secret.ID, + expectedAccess: false, + }, + { + name: "OwnerCannotAccessUserSecrets", + subject: rbac.Subject{ + ID: owner.ID.String(), + Roles: rbac.RoleIdentifiers{rbac.RoleOwner()}, + Scope: rbac.ScopeAll, + }, + secretID: user1Secret.ID, + expectedAccess: false, + }, + { + name: "OrgAdminCannotAccessUserSecrets", + subject: rbac.Subject{ + ID: orgAdmin.ID.String(), + Roles: rbac.RoleIdentifiers{rbac.ScopedRoleOrgAdmin(org.ID)}, + Scope: rbac.ScopeAll, + }, + secretID: user1Secret.ID, + expectedAccess: false, + }, + } + + for _, tc := range testCases { + tc := tc // capture range variable + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + authCtx := dbauthz.As(ctx, tc.subject) + + // Test GetUserSecret + _, err := authDB.GetUserSecret(authCtx, tc.secretID) + + if tc.expectedAccess { + require.NoError(t, err, "expected access to be granted") + } else { + require.Error(t, err, "expected access to be denied") + assert.True(t, dbauthz.IsNotAuthorizedError(err), "expected authorization error") + } + }) + } +} + func TestWorkspaceBuildDeadlineConstraint(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4adc936683067..74cefd09359b0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -13807,6 +13807,196 @@ func (q *sqlQuerier) UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinke return i, err } +const createUserSecret = `-- name: CreateUserSecret :one +INSERT INTO user_secrets ( + id, + user_id, + name, + description, + value, + env_name, + file_path +) VALUES ( + $1, $2, $3, $4, $5, $6, $7 +) RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at +` + +type CreateUserSecretParams struct { + ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` + Description string `db:"description" json:"description"` + Value string `db:"value" json:"value"` + EnvName string `db:"env_name" json:"env_name"` + FilePath string `db:"file_path" json:"file_path"` +} + +func (q *sqlQuerier) CreateUserSecret(ctx context.Context, arg CreateUserSecretParams) (UserSecret, error) { + row := q.db.QueryRowContext(ctx, createUserSecret, + arg.ID, + arg.UserID, + arg.Name, + arg.Description, + arg.Value, + arg.EnvName, + arg.FilePath, + ) + var i UserSecret + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.Value, + &i.EnvName, + &i.FilePath, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteUserSecret = `-- name: DeleteUserSecret :exec +DELETE FROM user_secrets +WHERE id = $1 +` + +func (q *sqlQuerier) DeleteUserSecret(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteUserSecret, id) + return err +} + +const getUserSecret = `-- name: GetUserSecret :one +SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at FROM user_secrets +WHERE id = $1 +` + +func (q *sqlQuerier) GetUserSecret(ctx context.Context, id uuid.UUID) (UserSecret, error) { + row := q.db.QueryRowContext(ctx, getUserSecret, id) + var i UserSecret + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.Value, + &i.EnvName, + &i.FilePath, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getUserSecretByUserIDAndName = `-- name: GetUserSecretByUserIDAndName :one +SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at FROM user_secrets +WHERE user_id = $1 AND name = $2 +` + +type GetUserSecretByUserIDAndNameParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` +} + +func (q *sqlQuerier) GetUserSecretByUserIDAndName(ctx context.Context, arg GetUserSecretByUserIDAndNameParams) (UserSecret, error) { + row := q.db.QueryRowContext(ctx, getUserSecretByUserIDAndName, arg.UserID, arg.Name) + var i UserSecret + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.Value, + &i.EnvName, + &i.FilePath, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listUserSecrets = `-- name: ListUserSecrets :many +SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at FROM user_secrets +WHERE user_id = $1 +ORDER BY name ASC +` + +func (q *sqlQuerier) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]UserSecret, error) { + rows, err := q.db.QueryContext(ctx, listUserSecrets, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UserSecret + for rows.Next() { + var i UserSecret + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.Value, + &i.EnvName, + &i.FilePath, + &i.CreatedAt, + &i.UpdatedAt, + ); 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 updateUserSecret = `-- name: UpdateUserSecret :one +UPDATE user_secrets +SET + description = $2, + value = $3, + env_name = $4, + file_path = $5, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at +` + +type UpdateUserSecretParams struct { + ID uuid.UUID `db:"id" json:"id"` + Description string `db:"description" json:"description"` + Value string `db:"value" json:"value"` + EnvName string `db:"env_name" json:"env_name"` + FilePath string `db:"file_path" json:"file_path"` +} + +func (q *sqlQuerier) UpdateUserSecret(ctx context.Context, arg UpdateUserSecretParams) (UserSecret, error) { + row := q.db.QueryRowContext(ctx, updateUserSecret, + arg.ID, + arg.Description, + arg.Value, + arg.EnvName, + arg.FilePath, + ) + var i UserSecret + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.Value, + &i.EnvName, + &i.FilePath, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const allUserIDs = `-- name: AllUserIDs :many SELECT DISTINCT id FROM USERS WHERE CASE WHEN $1::bool THEN TRUE ELSE is_system = false END diff --git a/coderd/database/queries/user_secrets.sql b/coderd/database/queries/user_secrets.sql new file mode 100644 index 0000000000000..271b97c9bb13c --- /dev/null +++ b/coderd/database/queries/user_secrets.sql @@ -0,0 +1,40 @@ +-- name: GetUserSecretByUserIDAndName :one +SELECT * FROM user_secrets +WHERE user_id = $1 AND name = $2; + +-- name: GetUserSecret :one +SELECT * FROM user_secrets +WHERE id = $1; + +-- name: ListUserSecrets :many +SELECT * FROM user_secrets +WHERE user_id = $1 +ORDER BY name ASC; + +-- name: CreateUserSecret :one +INSERT INTO user_secrets ( + id, + user_id, + name, + description, + value, + env_name, + file_path +) VALUES ( + $1, $2, $3, $4, $5, $6, $7 +) RETURNING *; + +-- name: UpdateUserSecret :one +UPDATE user_secrets +SET + description = $2, + value = $3, + env_name = $4, + file_path = $5, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +RETURNING *; + +-- name: DeleteUserSecret :exec +DELETE FROM user_secrets +WHERE id = $1; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 38c95e67410c9..3ed326102b18c 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -70,6 +70,7 @@ const ( UniqueUserConfigsPkey UniqueConstraint = "user_configs_pkey" // ALTER TABLE ONLY user_configs ADD CONSTRAINT user_configs_pkey PRIMARY KEY (user_id, key); UniqueUserDeletedPkey UniqueConstraint = "user_deleted_pkey" // ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_pkey PRIMARY KEY (id); UniqueUserLinksPkey UniqueConstraint = "user_links_pkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); + UniqueUserSecretsPkey UniqueConstraint = "user_secrets_pkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_pkey PRIMARY KEY (id); UniqueUserStatusChangesPkey UniqueConstraint = "user_status_changes_pkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id); UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); UniqueWebpushSubscriptionsPkey UniqueConstraint = "webpush_subscriptions_pkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_pkey PRIMARY KEY (id); @@ -115,6 +116,9 @@ const ( UniqueTemplateUsageStatsStartTimeTemplateIDUserIDIndex UniqueConstraint = "template_usage_stats_start_time_template_id_user_id_idx" // CREATE UNIQUE INDEX template_usage_stats_start_time_template_id_user_id_idx ON template_usage_stats USING btree (start_time, template_id, user_id); UniqueTemplatesOrganizationIDNameIndex UniqueConstraint = "templates_organization_id_name_idx" // CREATE UNIQUE INDEX templates_organization_id_name_idx ON templates USING btree (organization_id, lower((name)::text)) WHERE (deleted = false); UniqueUserLinksLinkedIDLoginTypeIndex UniqueConstraint = "user_links_linked_id_login_type_idx" // CREATE UNIQUE INDEX user_links_linked_id_login_type_idx ON user_links USING btree (linked_id, login_type) WHERE (linked_id <> ''::text); + UniqueUserSecretsUserEnvNameIndex UniqueConstraint = "user_secrets_user_env_name_idx" // CREATE UNIQUE INDEX user_secrets_user_env_name_idx ON user_secrets USING btree (user_id, env_name) WHERE (env_name <> ''::text); + UniqueUserSecretsUserFilePathIndex UniqueConstraint = "user_secrets_user_file_path_idx" // CREATE UNIQUE INDEX user_secrets_user_file_path_idx ON user_secrets USING btree (user_id, file_path) WHERE (file_path <> ''::text); + UniqueUserSecretsUserNameIndex UniqueConstraint = "user_secrets_user_name_idx" // CREATE UNIQUE INDEX user_secrets_user_name_idx ON user_secrets USING btree (user_id, name); UniqueUsersEmailLowerIndex UniqueConstraint = "users_email_lower_idx" // CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE (deleted = false); UniqueUsersUsernameLowerIndex UniqueConstraint = "users_username_lower_idx" // CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false); UniqueWorkspaceAppAuditSessionsUniqueIndex UniqueConstraint = "workspace_app_audit_sessions_unique_index" // CREATE UNIQUE INDEX workspace_app_audit_sessions_unique_index ON workspace_app_audit_sessions USING btree (agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code); diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 5fb3cc2bd8a3b..ca7f23b4af280 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -301,6 +301,16 @@ var ( Type: "user", } + // ResourceUserSecret + // Valid Actions + // - "ActionCreate" :: create a user secret + // - "ActionDelete" :: delete a user secret + // - "ActionRead" :: read user secret metadata and value + // - "ActionUpdate" :: update user secret metadata and value + ResourceUserSecret = Object{ + Type: "user_secret", + } + // ResourceWebpushSubscription // Valid Actions // - "ActionCreate" :: create webpush subscriptions @@ -403,6 +413,7 @@ func AllResources() []Objecter { ResourceTailnetCoordinator, ResourceTemplate, ResourceUser, + ResourceUserSecret, ResourceWebpushSubscription, ResourceWorkspace, ResourceWorkspaceAgentDevcontainers, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 8f05bbdbe544f..5ba79c6434d44 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -343,4 +343,12 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionCreate: "create workspace agent devcontainers", }, }, + "user_secret": { + Actions: map[Action]ActionDefinition{ + ActionCreate: "create a user secret", + ActionRead: "read user secret metadata and value", + ActionUpdate: "update user secret metadata and value", + ActionDelete: "delete a user secret", + }, + }, } diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index b8d3f959ce477..33635f34e5914 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -269,8 +269,9 @@ func ReloadBuiltinRoles(opts *RoleOptions) { DisplayName: "Owner", Site: append( // Workspace dormancy and workspace are omitted. - // Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec - allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace), + // Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec. + // Owners cannot access other users' secrets. + allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret), // This adds back in the Workspace permissions. Permissions(map[string][]policy.Action{ ResourceWorkspace.Type: ownerWorkspaceActions, @@ -417,7 +418,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { }), Org: map[string][]Permission{ // Org admins should not have workspace exec perms. - organizationID.String(): append(allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole), Permissions(map[string][]policy.Action{ + organizationID.String(): append(allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret), Permissions(map[string][]policy.Action{ ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent}, ResourceWorkspace.Type: slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH), // PrebuiltWorkspaces are a subset of Workspaces. diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 267a99993e642..f79a6408df79b 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -858,6 +858,20 @@ func TestRolePermissions(t *testing.T) { false: {setOtherOrg, setOrgNotMe, memberMe, orgMemberMe, templateAdmin, userAdmin}, }, }, + // Only the user themselves can access their own secrets — no one else. + { + Name: "UserSecrets", + Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + Resource: rbac.ResourceUserSecret.WithOwner(currentUser.String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {memberMe, orgMemberMe}, + false: { + owner, orgAdmin, + otherOrgAdmin, otherOrgMember, orgAuditor, orgUserAdmin, orgTemplateAdmin, + templateAdmin, userAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, + }, + }, + }, } // We expect every permission to be tested above. diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 3e22d29c73297..9dd2056b781b4 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -36,6 +36,7 @@ const ( ResourceTailnetCoordinator RBACResource = "tailnet_coordinator" ResourceTemplate RBACResource = "template" ResourceUser RBACResource = "user" + ResourceUserSecret RBACResource = "user_secret" ResourceWebpushSubscription RBACResource = "webpush_subscription" ResourceWorkspace RBACResource = "workspace" ResourceWorkspaceAgentDevcontainers RBACResource = "workspace_agent_devcontainers" @@ -100,6 +101,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceTailnetCoordinator: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceTemplate: {ActionCreate, ActionDelete, ActionRead, ActionUpdate, ActionUse, ActionViewInsights}, ResourceUser: {ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal}, + ResourceUserSecret: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceWebpushSubscription: {ActionCreate, ActionDelete, ActionRead}, ResourceWorkspace: {ActionApplicationConnect, ActionCreate, ActionCreateAgent, ActionDelete, ActionDeleteAgent, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, ResourceWorkspaceAgentDevcontainers: {ActionCreate}, diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index 4b0adbf45e338..0533da5114482 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -214,6 +214,7 @@ Status Code **200** | `resource_type` | `tailnet_coordinator` | | `resource_type` | `template` | | `resource_type` | `user` | +| `resource_type` | `user_secret` | | `resource_type` | `webpush_subscription` | | `resource_type` | `workspace` | | `resource_type` | `workspace_agent_devcontainers` | @@ -384,6 +385,7 @@ Status Code **200** | `resource_type` | `tailnet_coordinator` | | `resource_type` | `template` | | `resource_type` | `user` | +| `resource_type` | `user_secret` | | `resource_type` | `webpush_subscription` | | `resource_type` | `workspace` | | `resource_type` | `workspace_agent_devcontainers` | @@ -554,6 +556,7 @@ Status Code **200** | `resource_type` | `tailnet_coordinator` | | `resource_type` | `template` | | `resource_type` | `user` | +| `resource_type` | `user_secret` | | `resource_type` | `webpush_subscription` | | `resource_type` | `workspace` | | `resource_type` | `workspace_agent_devcontainers` | @@ -693,6 +696,7 @@ Status Code **200** | `resource_type` | `tailnet_coordinator` | | `resource_type` | `template` | | `resource_type` | `user` | +| `resource_type` | `user_secret` | | `resource_type` | `webpush_subscription` | | `resource_type` | `workspace` | | `resource_type` | `workspace_agent_devcontainers` | @@ -1054,6 +1058,7 @@ Status Code **200** | `resource_type` | `tailnet_coordinator` | | `resource_type` | `template` | | `resource_type` | `user` | +| `resource_type` | `user_secret` | | `resource_type` | `webpush_subscription` | | `resource_type` | `workspace` | | `resource_type` | `workspace_agent_devcontainers` | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 581743ea7cc22..b3824d0c9b9b8 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -6379,6 +6379,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `tailnet_coordinator` | | `template` | | `user` | +| `user_secret` | | `webpush_subscription` | | `workspace` | | `workspace_agent_devcontainers` | diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 5d632d57fad95..33ff18e8ce4d6 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -167,6 +167,12 @@ export const RBACResourceActions: Partial< update: "update an existing user", update_personal: "update personal data", }, + user_secret: { + create: "create a user secret", + delete: "delete a user secret", + read: "read user secret metadata and value", + update: "update user secret metadata and value", + }, webpush_subscription: { create: "create webpush subscriptions", delete: "delete webpush subscriptions", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index db901630b71cf..52fdb1d6effc4 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2386,6 +2386,7 @@ export type RBACResource = | "tailnet_coordinator" | "template" | "user" + | "user_secret" | "webpush_subscription" | "*" | "workspace" @@ -2426,6 +2427,7 @@ export const RBACResources: RBACResource[] = [ "tailnet_coordinator", "template", "user", + "user_secret", "webpush_subscription", "*", "workspace",
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: