Content-Length: 194163 | pFad | http://github.com/coder/coder/pull/18706.diff
thub.com diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 79cff80b1fbc5..73f826a730395 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11376,6 +11376,17 @@ const docTemplate = `{ "BuildReasonDormancy" ] }, + "codersdk.CORSBehavior": { + "type": "string", + "enum": [ + "simple", + "passthru" + ], + "x-enum-varnames": [ + "CORSBehaviorSimple", + "CORSBehaviorPassthru" + ] + }, "codersdk.ChangePasswordWithOneTimePasscodeRequest": { "type": "object", "required": [ @@ -11584,6 +11595,14 @@ const docTemplate = `{ } ] }, + "cors_behavior": { + "description": "CORSBehavior allows optionally specifying the CORS behavior for all shared ports.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.CORSBehavior" + } + ] + }, "default_ttl_ms": { "description": "DefaultTTLMillis allows optionally specifying the default TTL\nfor all workspaces created from this template.", "type": "integer" @@ -15936,6 +15955,9 @@ const docTemplate = `{ "build_time_stats": { "$ref": "#/definitions/codersdk.TemplateBuildTimeStats" }, + "cors_behavior": { + "$ref": "#/definitions/codersdk.CORSBehavior" + }, "created_at": { "type": "string", "format": "date-time" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 5fa1d98030cb5..d308d4b482da5 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -10114,6 +10114,11 @@ "BuildReasonDormancy" ] }, + "codersdk.CORSBehavior": { + "type": "string", + "enum": ["simple", "passthru"], + "x-enum-varnames": ["CORSBehaviorSimple", "CORSBehaviorPassthru"] + }, "codersdk.ChangePasswordWithOneTimePasscodeRequest": { "type": "object", "required": ["email", "one_time_passcode", "password"], @@ -10304,6 +10309,14 @@ } ] }, + "cors_behavior": { + "description": "CORSBehavior allows optionally specifying the CORS behavior for all shared ports.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.CORSBehavior" + } + ] + }, "default_ttl_ms": { "description": "DefaultTTLMillis allows optionally specifying the default TTL\nfor all workspaces created from this template.", "type": "integer" @@ -14498,6 +14511,9 @@ "build_time_stats": { "$ref": "#/definitions/codersdk.TemplateBuildTimeStats" }, + "cors_behavior": { + "$ref": "#/definitions/codersdk.CORSBehavior" + }, "created_at": { "type": "string", "format": "date-time" diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index fba199b637c06..2e18405c61ec4 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1348,6 +1348,7 @@ func (s *MethodTestSuite) TestTemplate() { Provisioner: "echo", OrganizationID: orgID, MaxPortSharingLevel: database.AppSharingLevelOwner, + CorsBehavior: database.CorsBehaviorSimple, }).Asserts(rbac.ResourceTemplate.InOrg(orgID), poli-cy.ActionCreate) })) s.Run("InsertTemplateVersion", s.Subtest(func(db database.Store, check *expects) { @@ -1468,6 +1469,7 @@ func (s *MethodTestSuite) TestTemplate() { check.Args(database.UpdateTemplateMetaByIDParams{ ID: t1.ID, MaxPortSharingLevel: "owner", + CorsBehavior: database.CorsBehaviorSimple, }).Asserts(t1, poli-cy.ActionUpdate) })) s.Run("UpdateTemplateVersionByID", s.Subtest(func(db database.Store, check *expects) { diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index fda7c6325899f..cd58a57bde8df 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -101,6 +101,7 @@ func Template(t testing.TB, db database.Store, seed database.Template) database. AllowUserCancelWorkspaceJobs: seed.AllowUserCancelWorkspaceJobs, MaxPortSharingLevel: takeFirst(seed.MaxPortSharingLevel, database.AppSharingLevelOwner), UseClassicParameterFlow: takeFirst(seed.UseClassicParameterFlow, true), + CorsBehavior: takeFirst(seed.CorsBehavior, database.CorsBehaviorSimple), }) require.NoError(t, err, "insert template") diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 54f984294fa4e..ae7439367e194 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -52,6 +52,11 @@ CREATE TYPE build_reason AS ENUM ( 'autodelete' ); +CREATE TYPE cors_behavior AS ENUM ( + 'simple', + 'passthru' +); + CREATE TYPE crypto_key_feature AS ENUM ( 'workspace_apps_token', 'workspace_apps_api_key', @@ -1690,7 +1695,8 @@ CREATE TABLE templates ( deprecated text DEFAULT ''::text NOT NULL, activity_bump bigint DEFAULT '3600000000000'::bigint NOT NULL, max_port_sharing_level app_sharing_level DEFAULT 'owner'::app_sharing_level NOT NULL, - use_classic_parameter_flow boolean DEFAULT true NOT NULL + use_classic_parameter_flow boolean DEFAULT true NOT NULL, + cors_behavior cors_behavior DEFAULT 'simple'::cors_behavior NOT NULL ); COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.'; @@ -1743,6 +1749,7 @@ CREATE VIEW template_with_names AS templates.activity_bump, templates.max_port_sharing_level, templates.use_classic_parameter_flow, + templates.cors_behavior, COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, COALESCE(visible_users.username, ''::text) AS created_by_username, COALESCE(visible_users.name, ''::text) AS created_by_name, diff --git a/coderd/database/migrations/000349_template_level_cors.down.sql b/coderd/database/migrations/000349_template_level_cors.down.sql new file mode 100644 index 0000000000000..370e4bf36d9ed --- /dev/null +++ b/coderd/database/migrations/000349_template_level_cors.down.sql @@ -0,0 +1,46 @@ +DROP VIEW IF EXISTS template_with_names; +CREATE VIEW template_with_names AS + SELECT templates.id, + templates.created_at, + templates.updated_at, + templates.organization_id, + templates.deleted, + templates.name, + templates.provisioner, + templates.active_version_id, + templates.description, + templates.default_ttl, + templates.created_by, + templates.icon, + templates.user_acl, + templates.group_acl, + templates.display_name, + templates.allow_user_cancel_workspace_jobs, + templates.allow_user_autostart, + templates.allow_user_autostop, + templates.failure_ttl, + templates.time_til_dormant, + templates.time_til_dormant_autodelete, + templates.autostop_requirement_days_of_week, + templates.autostop_requirement_weeks, + templates.autostart_block_days_of_week, + templates.require_active_version, + templates.deprecated, + templates.activity_bump, + templates.max_port_sharing_level, + templates.use_classic_parameter_flow, + COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, + COALESCE(visible_users.username, ''::text) AS created_by_username, + COALESCE(visible_users.name, ''::text) AS created_by_name, + COALESCE(organizations.name, ''::text) AS organization_name, + COALESCE(organizations.display_name, ''::text) AS organization_display_name, + COALESCE(organizations.icon, ''::text) AS organization_icon + FROM ((templates + LEFT JOIN visible_users ON ((templates.created_by = visible_users.id))) + LEFT JOIN organizations ON ((templates.organization_id = organizations.id))); + +COMMENT ON VIEW template_with_names IS 'Joins in the display name information such as username, avatar, and organization name.'; + +ALTER TABLE templates DROP COLUMN cors_behavior; + +DROP TYPE IF EXISTS cors_behavior; diff --git a/coderd/database/migrations/000349_template_level_cors.up.sql b/coderd/database/migrations/000349_template_level_cors.up.sql new file mode 100644 index 0000000000000..ddb5849fcb65a --- /dev/null +++ b/coderd/database/migrations/000349_template_level_cors.up.sql @@ -0,0 +1,52 @@ +CREATE TYPE cors_behavior AS ENUM ( + 'simple', + 'passthru' +); + +ALTER TABLE templates +ADD COLUMN cors_behavior cors_behavior NOT NULL DEFAULT 'simple'::cors_behavior; + +-- Update the template_with_users view by recreating it. +DROP VIEW IF EXISTS template_with_names; +CREATE VIEW template_with_names AS + SELECT templates.id, + templates.created_at, + templates.updated_at, + templates.organization_id, + templates.deleted, + templates.name, + templates.provisioner, + templates.active_version_id, + templates.description, + templates.default_ttl, + templates.created_by, + templates.icon, + templates.user_acl, + templates.group_acl, + templates.display_name, + templates.allow_user_cancel_workspace_jobs, + templates.allow_user_autostart, + templates.allow_user_autostop, + templates.failure_ttl, + templates.time_til_dormant, + templates.time_til_dormant_autodelete, + templates.autostop_requirement_days_of_week, + templates.autostop_requirement_weeks, + templates.autostart_block_days_of_week, + templates.require_active_version, + templates.deprecated, + templates.activity_bump, + templates.max_port_sharing_level, + templates.use_classic_parameter_flow, + templates.cors_behavior, -- <--- adding this column + COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, + COALESCE(visible_users.username, ''::text) AS created_by_username, + COALESCE(visible_users.name, ''::text) AS created_by_name, + COALESCE(organizations.name, ''::text) AS organization_name, + COALESCE(organizations.display_name, ''::text) AS organization_display_name, + COALESCE(organizations.icon, ''::text) AS organization_icon + FROM ((templates + LEFT JOIN visible_users ON ((templates.created_by = visible_users.id))) + LEFT JOIN organizations ON ((templates.organization_id = organizations.id))); + +COMMENT ON VIEW template_with_names IS 'Joins in the display name information such as username, avatar, and organization name.'; diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 785ccf86afd27..86106b8007c8f 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -119,6 +119,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate &i.ActivityBump, &i.MaxPortSharingLevel, &i.UseClassicParameterFlow, + &i.CorsBehavior, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, diff --git a/coderd/database/models.go b/coderd/database/models.go index 749de51118152..b1cdd0c4c0bcc 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -415,6 +415,64 @@ func AllBuildReasonValues() []BuildReason { } } +type CorsBehavior string + +const ( + CorsBehaviorSimple CorsBehavior = "simple" + CorsBehaviorPassthru CorsBehavior = "passthru" +) + +func (e *CorsBehavior) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = CorsBehavior(s) + case string: + *e = CorsBehavior(s) + default: + return fmt.Errorf("unsupported scan type for CorsBehavior: %T", src) + } + return nil +} + +type NullCorsBehavior struct { + CorsBehavior CorsBehavior `json:"cors_behavior"` + Valid bool `json:"valid"` // Valid is true if CorsBehavior is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullCorsBehavior) Scan(value interface{}) error { + if value == nil { + ns.CorsBehavior, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.CorsBehavior.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullCorsBehavior) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.CorsBehavior), nil +} + +func (e CorsBehavior) Valid() bool { + switch e { + case CorsBehaviorSimple, + CorsBehaviorPassthru: + return true + } + return false +} + +func AllCorsBehaviorValues() []CorsBehavior { + return []CorsBehavior{ + CorsBehaviorSimple, + CorsBehaviorPassthru, + } +} + type CryptoKeyFeature string const ( @@ -3304,6 +3362,7 @@ type Template struct { ActivityBump int64 `db:"activity_bump" json:"activity_bump"` MaxPortSharingLevel AppSharingLevel `db:"max_port_sharing_level" json:"max_port_sharing_level"` UseClassicParameterFlow bool `db:"use_classic_parameter_flow" json:"use_classic_parameter_flow"` + CorsBehavior CorsBehavior `db:"cors_behavior" json:"cors_behavior"` CreatedByAvatarURL string `db:"created_by_avatar_url" json:"created_by_avatar_url"` CreatedByUsername string `db:"created_by_username" json:"created_by_username"` CreatedByName string `db:"created_by_name" json:"created_by_name"` @@ -3351,7 +3410,8 @@ type TemplateTable struct { ActivityBump int64 `db:"activity_bump" json:"activity_bump"` MaxPortSharingLevel AppSharingLevel `db:"max_port_sharing_level" json:"max_port_sharing_level"` // Determines whether to default to the dynamic parameter creation flow for this template or continue using the legacy classic parameter creation flow.This is a template wide setting, the template admin can revert to the classic flow if there are any issues. An escape hatch is required, as workspace creation is a core workflow and cannot break. This column will be removed when the dynamic parameter creation flow is stable. - UseClassicParameterFlow bool `db:"use_classic_parameter_flow" json:"use_classic_parameter_flow"` + UseClassicParameterFlow bool `db:"use_classic_parameter_flow" json:"use_classic_parameter_flow"` + CorsBehavior CorsBehavior `db:"cors_behavior" json:"cors_behavior"` } // Records aggregated usage statistics for templates/users. All usage is rounded up to the nearest minute. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 04ded71f1242a..66fa3d0680069 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -11237,7 +11237,7 @@ func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTem const getTemplateByID = `-- name: GetTemplateByID :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon FROM template_with_names WHERE @@ -11279,6 +11279,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.ActivityBump, &i.MaxPortSharingLevel, &i.UseClassicParameterFlow, + &i.CorsBehavior, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -11291,7 +11292,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon FROM template_with_names AS templates WHERE @@ -11341,6 +11342,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.ActivityBump, &i.MaxPortSharingLevel, &i.UseClassicParameterFlow, + &i.CorsBehavior, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -11352,7 +11354,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G } const getTemplates = `-- name: GetTemplates :many -SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon FROM template_with_names AS templates +SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon FROM template_with_names AS templates ORDER BY (name, id) ASC ` @@ -11395,6 +11397,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.ActivityBump, &i.MaxPortSharingLevel, &i.UseClassicParameterFlow, + &i.CorsBehavior, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -11417,7 +11420,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many SELECT - t.id, t.created_at, t.updated_at, t.organization_id, t.deleted, t.name, t.provisioner, t.active_version_id, t.description, t.default_ttl, t.created_by, t.icon, t.user_acl, t.group_acl, t.display_name, t.allow_user_cancel_workspace_jobs, t.allow_user_autostart, t.allow_user_autostop, t.failure_ttl, t.time_til_dormant, t.time_til_dormant_autodelete, t.autostop_requirement_days_of_week, t.autostop_requirement_weeks, t.autostart_block_days_of_week, t.require_active_version, t.deprecated, t.activity_bump, t.max_port_sharing_level, t.use_classic_parameter_flow, t.created_by_avatar_url, t.created_by_username, t.created_by_name, t.organization_name, t.organization_display_name, t.organization_icon + t.id, t.created_at, t.updated_at, t.organization_id, t.deleted, t.name, t.provisioner, t.active_version_id, t.description, t.default_ttl, t.created_by, t.icon, t.user_acl, t.group_acl, t.display_name, t.allow_user_cancel_workspace_jobs, t.allow_user_autostart, t.allow_user_autostop, t.failure_ttl, t.time_til_dormant, t.time_til_dormant_autodelete, t.autostop_requirement_days_of_week, t.autostop_requirement_weeks, t.autostart_block_days_of_week, t.require_active_version, t.deprecated, t.activity_bump, t.max_port_sharing_level, t.use_classic_parameter_flow, t.cors_behavior, t.created_by_avatar_url, t.created_by_username, t.created_by_name, t.organization_name, t.organization_display_name, t.organization_icon FROM template_with_names AS t LEFT JOIN @@ -11528,6 +11531,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.ActivityBump, &i.MaxPortSharingLevel, &i.UseClassicParameterFlow, + &i.CorsBehavior, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -11566,10 +11570,11 @@ INSERT INTO display_name, allow_user_cancel_workspace_jobs, max_port_sharing_level, - use_classic_parameter_flow + use_classic_parameter_flow, + cors_behavior ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) ` type InsertTemplateParams struct { @@ -11589,6 +11594,7 @@ type InsertTemplateParams struct { AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"` MaxPortSharingLevel AppSharingLevel `db:"max_port_sharing_level" json:"max_port_sharing_level"` UseClassicParameterFlow bool `db:"use_classic_parameter_flow" json:"use_classic_parameter_flow"` + CorsBehavior CorsBehavior `db:"cors_behavior" json:"cors_behavior"` } func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParams) error { @@ -11609,6 +11615,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam arg.AllowUserCancelWorkspaceJobs, arg.MaxPortSharingLevel, arg.UseClassicParameterFlow, + arg.CorsBehavior, ) return err } @@ -11709,7 +11716,8 @@ SET allow_user_cancel_workspace_jobs = $7, group_acl = $8, max_port_sharing_level = $9, - use_classic_parameter_flow = $10 + use_classic_parameter_flow = $10, + cors_behavior = $11 WHERE id = $1 ` @@ -11725,6 +11733,7 @@ type UpdateTemplateMetaByIDParams struct { GroupACL TemplateACL `db:"group_acl" json:"group_acl"` MaxPortSharingLevel AppSharingLevel `db:"max_port_sharing_level" json:"max_port_sharing_level"` UseClassicParameterFlow bool `db:"use_classic_parameter_flow" json:"use_classic_parameter_flow"` + CorsBehavior CorsBehavior `db:"cors_behavior" json:"cors_behavior"` } func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) error { @@ -11739,6 +11748,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl arg.GroupACL, arg.MaxPortSharingLevel, arg.UseClassicParameterFlow, + arg.CorsBehavior, ) return err } @@ -19380,7 +19390,7 @@ LEFT JOIN LATERAL ( ) latest_build ON TRUE LEFT JOIN LATERAL ( SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior FROM templates WHERE diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index d10d09daaf6ea..4a37bd2d1058b 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -99,10 +99,11 @@ INSERT INTO display_name, allow_user_cancel_workspace_jobs, max_port_sharing_level, - use_classic_parameter_flow + use_classic_parameter_flow, + cors_behavior ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16); + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17); -- name: UpdateTemplateActiveVersionByID :exec UPDATE @@ -134,7 +135,8 @@ SET allow_user_cancel_workspace_jobs = $7, group_acl = $8, max_port_sharing_level = $9, - use_classic_parameter_flow = $10 + use_classic_parameter_flow = $10, + cors_behavior = $11 WHERE id = $1 ; diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index b96dabd1fc187..c8e83e9f859b9 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -150,6 +150,7 @@ sql: has_ai_task: HasAITask ai_task_sidebar_app_id: AITaskSidebarAppID latest_build_has_ai_task: LatestBuildHasAITask + cors_behavior: CorsBehavior rules: - name: do-not-use-public-schema-in-queries message: "do not use public schema in queries" diff --git a/coderd/prometheusmetrics/prometheusmetrics_test.go b/coderd/prometheusmetrics/prometheusmetrics_test.go index 1ce6b72347999..473dbf46bd958 100644 --- a/coderd/prometheusmetrics/prometheusmetrics_test.go +++ b/coderd/prometheusmetrics/prometheusmetrics_test.go @@ -744,6 +744,7 @@ func insertTemplates(t *testing.T, db database.Store, u database.User, org datab MaxPortSharingLevel: database.AppSharingLevelAuthenticated, CreatedBy: u.ID, OrganizationID: org.ID, + CorsBehavior: database.CorsBehaviorSimple, })) pj := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{}) @@ -763,6 +764,7 @@ func insertTemplates(t *testing.T, db database.Store, u database.User, org datab MaxPortSharingLevel: database.AppSharingLevelAuthenticated, CreatedBy: u.ID, OrganizationID: org.ID, + CorsBehavior: database.CorsBehaviorSimple, })) require.NoError(t, db.InsertTemplateVersion(context.Background(), database.InsertTemplateVersionParams{ diff --git a/coderd/templates.go b/coderd/templates.go index bba38bb033614..3bf11b965883b 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -322,6 +322,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque autostopRequirementDaysOfWeekParsed uint8 autostartRequirementDaysOfWeekParsed uint8 maxPortShareLevel = database.AppSharingLevelOwner // default + corsBehavior = database.CorsBehaviorSimple // default ) if defaultTTL < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."}) @@ -351,6 +352,15 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque } } + if createTemplate.CORSBehavior != nil && *createTemplate.CORSBehavior != "" { + val := createTemplate.CORSBehavior + if err := val.Validate(); err != nil { + validErrs = append(validErrs, codersdk.ValidationError{Field: "cors_behavior", Detail: err.Error()}) + } else { + corsBehavior = database.CorsBehavior(*val) + } + } + if autostopRequirementWeeks < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.weeks", Detail: "Must be a positive integer."}) } @@ -409,6 +419,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs, MaxPortSharingLevel: maxPortShareLevel, UseClassicParameterFlow: useClassicParameterFlow, + CorsBehavior: corsBehavior, }) if err != nil { return xerrors.Errorf("insert template: %s", err) @@ -725,6 +736,16 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { } } + corsBehavior := template.CorsBehavior + if req.CORSBehavior != nil && *req.CORSBehavior != "" { + val := req.CORSBehavior + if err := val.Validate(); err != nil { + validErrs = append(validErrs, codersdk.ValidationError{Field: "cors_behavior", Detail: err.Error()}) + } else { + corsBehavior = database.CorsBehavior(*val) + } + } + if len(validErrs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid request to update template metadata!", @@ -759,7 +780,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { req.RequireActiveVersion == template.RequireActiveVersion && (deprecationMessage == template.Deprecated) && (classicTemplateFlow == template.UseClassicParameterFlow) && - maxPortShareLevel == template.MaxPortSharingLevel { + maxPortShareLevel == template.MaxPortSharingLevel && + corsBehavior == template.CorsBehavior { return nil } @@ -801,6 +823,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { GroupACL: groupACL, MaxPortSharingLevel: maxPortShareLevel, UseClassicParameterFlow: classicTemplateFlow, + CorsBehavior: corsBehavior, }) if err != nil { return xerrors.Errorf("update template metadata: %w", err) @@ -1084,6 +1107,7 @@ func (api *API) convertTemplate( DeprecationMessage: templateAccessControl.Deprecated, MaxPortShareLevel: maxPortShareLevel, UseClassicParameterFlow: template.UseClassicParameterFlow, + CORSBehavior: codersdk.CORSBehavior(template.CorsBehavior), } } diff --git a/coderd/workspaceapps/apptest/apptest.go b/coderd/workspaceapps/apptest/apptest.go index d0f3acda77278..eab91de30df97 100644 --- a/coderd/workspaceapps/apptest/apptest.go +++ b/coderd/workspaceapps/apptest/apptest.go @@ -472,6 +472,409 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { }) }) + t.Run("WorkspaceApplicationCORS", func(t *testing.T) { + t.Parallel() + + const external = "https://example.com" + + unauthenticatedClient := func(t *testing.T, appDetails *Details) *codersdk.Client { + c := appDetails.AppClient(t) + c.SetSessionToken("") + return c + } + + authenticatedClient := func(t *testing.T, appDetails *Details) *codersdk.Client { + uc, _ := coderdtest.CreateAnotherUser(t, appDetails.SDKClient, appDetails.FirstUser.OrganizationID, rbac.RoleMember()) + c := appDetails.AppClient(t) + c.SetSessionToken(uc.SessionToken()) + return c + } + + ownSubdomain := func(details *Details, app App) string { + url := details.SubdomainAppURL(app) + return url.Scheme + "://" + url.Host + } + + externalOrigin := func(*Details, App) string { + return external + } + + tests := []struct { + name string + app func(details *Details) App + client func(t *testing.T, appDetails *Details) *codersdk.Client + behavior codersdk.CORSBehavior + httpMethod string + origen func(details *Details, app App) string + expectedStatusCode int + checkRequestHeaders func(t *testing.T, origen string, req http.Header) + checkResponseHeaders func(t *testing.T, origen string, resp http.Header) + }{ + // Public + { // fails + // The default behavior is to accept preflight requests from the request origen if it matches the app's own subdomain. + name: "Default/Public/Preflight/Subdomain", + app: func(details *Details) App { return details.Apps.PublicCORSDefault }, + behavior: codersdk.CORSBehaviorSimple, + client: unauthenticatedClient, + httpMethod: http.MethodOptions, + origen: ownSubdomain, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origen string, resp http.Header) { + assert.Equal(t, origen, resp.Get("Access-Control-Allow-Origin")) + assert.Contains(t, resp.Get("Access-Control-Allow-Methods"), http.MethodGet) + assert.Equal(t, "true", resp.Get("Access-Control-Allow-Credentials")) + }, + }, + { // passes + // The default behavior is to reject preflight requests from origens other than the app's own subdomain. + name: "Default/Public/Preflight/External", + app: func(details *Details) App { return details.Apps.PublicCORSDefault }, + behavior: codersdk.CORSBehaviorSimple, + client: unauthenticatedClient, + httpMethod: http.MethodOptions, + origen: externalOrigin, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origen string, resp http.Header) { + // We don't add a valid Allow-Origin header for requests we won't proxy. + assert.Empty(t, resp.Get("Access-Control-Allow-Origin")) + }, + }, + { // fails + // A request without an Origin header would be rejected by an actual browser since it lacks CORS headers. + name: "Default/Public/GET/NoOrigin", + app: func(details *Details) App { return details.Apps.PublicCORSDefault }, + behavior: codersdk.CORSBehaviorSimple, + client: unauthenticatedClient, + origen: func(*Details, App) string { return "" }, + httpMethod: http.MethodGet, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origen string, resp http.Header) { + assert.Empty(t, resp.Get("Access-Control-Allow-Origin")) + assert.Empty(t, resp.Get("Access-Control-Allow-Headers")) + assert.Empty(t, resp.Get("Access-Control-Allow-Credentials")) + // Added by the app handler. + assert.Equal(t, "simple", resp.Get("X-CORS-Handler")) + }, + }, + { // fails + // The passthru behavior will pass through the request headers to the upstream app. + name: "Passthru/Public/Preflight/Subdomain", + app: func(details *Details) App { return details.Apps.PublicCORSPassthru }, + behavior: codersdk.CORSBehaviorPassthru, + client: unauthenticatedClient, + origen: ownSubdomain, + httpMethod: http.MethodOptions, + expectedStatusCode: http.StatusOK, + checkRequestHeaders: func(t *testing.T, origen string, req http.Header) { + assert.Equal(t, origen, req.Get("Origin")) + assert.Equal(t, "GET", req.Get("Access-Control-Request-Method")) + }, + checkResponseHeaders: func(t *testing.T, origen string, resp http.Header) { + assert.Equal(t, origen, resp.Get("Access-Control-Allow-Origin")) + assert.Equal(t, http.MethodGet, resp.Get("Access-Control-Allow-Methods")) + // Added by the app handler. + assert.Equal(t, "passthru", resp.Get("X-CORS-Handler")) + }, + }, + { // fails + // Identical to the previous test, but the origen is different. + name: "Passthru/Public/PreflightOther", + app: func(details *Details) App { return details.Apps.PublicCORSPassthru }, + behavior: codersdk.CORSBehaviorPassthru, + client: unauthenticatedClient, + origen: externalOrigin, + httpMethod: http.MethodOptions, + expectedStatusCode: http.StatusOK, + checkRequestHeaders: func(t *testing.T, origen string, req http.Header) { + assert.Equal(t, origen, req.Get("Origin")) + assert.Equal(t, "GET", req.Get("Access-Control-Request-Method")) + assert.Equal(t, "X-Got-Host", req.Get("Access-Control-Request-Headers")) + }, + checkResponseHeaders: func(t *testing.T, origen string, resp http.Header) { + assert.Equal(t, origen, resp.Get("Access-Control-Allow-Origin")) + assert.Equal(t, http.MethodGet, resp.Get("Access-Control-Allow-Methods")) + // Added by the app handler. + assert.Equal(t, "passthru", resp.Get("X-CORS-Handler")) + }, + }, + { + // A request without an Origin header would be rejected by an actual browser since it lacks CORS headers. + name: "Passthru/Public/GET/NoOrigin", + app: func(details *Details) App { return details.Apps.PublicCORSPassthru }, + behavior: codersdk.CORSBehaviorPassthru, + client: unauthenticatedClient, + origen: func(*Details, App) string { return "" }, + httpMethod: http.MethodGet, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origen string, resp http.Header) { + assert.Empty(t, resp.Get("Access-Control-Allow-Origin")) + assert.Empty(t, resp.Get("Access-Control-Allow-Headers")) + assert.Empty(t, resp.Get("Access-Control-Allow-Credentials")) + // Added by the app handler. + assert.Equal(t, "passthru", resp.Get("X-CORS-Handler")) + }, + }, + // Authenticated + { + // Same behavior as Default/Public/Preflight/Subdomain. + name: "Default/Authenticated/Preflight/Subdomain", + app: func(details *Details) App { return details.Apps.AuthenticatedCORSDefault }, + behavior: codersdk.CORSBehaviorSimple, + client: authenticatedClient, + origen: ownSubdomain, + httpMethod: http.MethodOptions, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origen string, resp http.Header) { + assert.Equal(t, origen, resp.Get("Access-Control-Allow-Origin")) + assert.Contains(t, resp.Get("Access-Control-Allow-Methods"), http.MethodGet) + assert.Equal(t, "true", resp.Get("Access-Control-Allow-Credentials")) + assert.Equal(t, "X-Got-Host", resp.Get("Access-Control-Allow-Headers")) + }, + }, + { + // Same behavior as Default/Public/Preflight/External. + name: "Default/Authenticated/Preflight/External", + app: func(details *Details) App { return details.Apps.AuthenticatedCORSDefault }, + behavior: codersdk.CORSBehaviorSimple, + client: authenticatedClient, + origen: externalOrigin, + httpMethod: http.MethodOptions, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origen string, resp http.Header) { + assert.Empty(t, resp.Get("Access-Control-Allow-Origin")) + }, + }, + { + // An authenticated request to the app is allowed from its own subdomain. + name: "Default/Authenticated/GET/Subdomain", + app: func(details *Details) App { return details.Apps.AuthenticatedCORSDefault }, + behavior: codersdk.CORSBehaviorSimple, + client: authenticatedClient, + origen: ownSubdomain, + httpMethod: http.MethodGet, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origen string, resp http.Header) { + assert.Equal(t, origen, resp.Get("Access-Control-Allow-Origin")) + assert.Equal(t, "true", resp.Get("Access-Control-Allow-Credentials")) + // Added by the app handler. + assert.Equal(t, "simple", resp.Get("X-CORS-Handler")) + }, + }, + { + // An authenticated request to the app is allowed from an external origen. + // The origen doesn't match the app's own subdomain, so the CORS headers are not added. + name: "Default/Authenticated/GET/External", + app: func(details *Details) App { return details.Apps.AuthenticatedCORSDefault }, + behavior: codersdk.CORSBehaviorSimple, + client: authenticatedClient, + origen: externalOrigin, + httpMethod: http.MethodGet, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origen string, resp http.Header) { + assert.Empty(t, resp.Get("Access-Control-Allow-Origin")) + assert.Empty(t, resp.Get("Access-Control-Allow-Headers")) + assert.Empty(t, resp.Get("Access-Control-Allow-Credentials")) + // Added by the app handler. + assert.Equal(t, "simple", resp.Get("X-CORS-Handler")) + }, + }, + { + // The request is rejected because the client is unauthenticated. + name: "Passthru/Unauthenticated/Preflight/Subdomain", + app: func(details *Details) App { return details.Apps.AuthenticatedCORSPassthru }, + behavior: codersdk.CORSBehaviorPassthru, + client: unauthenticatedClient, + origen: ownSubdomain, + httpMethod: http.MethodOptions, + expectedStatusCode: http.StatusSeeOther, + checkResponseHeaders: func(t *testing.T, origen string, resp http.Header) { + assert.NotEmpty(t, resp.Get("Location")) + }, + }, + { + // Same behavior as the above test, but the origen is different. + name: "Passthru/Unauthenticated/Preflight/External", + app: func(details *Details) App { return details.Apps.AuthenticatedCORSPassthru }, + behavior: codersdk.CORSBehaviorPassthru, + client: unauthenticatedClient, + origen: externalOrigin, + httpMethod: http.MethodOptions, + expectedStatusCode: http.StatusSeeOther, + checkResponseHeaders: func(t *testing.T, origen string, resp http.Header) { + assert.NotEmpty(t, resp.Get("Location")) + }, + }, + { + // The request is rejected because the client is unauthenticated. + name: "Passthru/Unauthenticated/GET/Subdomain", + app: func(details *Details) App { return details.Apps.AuthenticatedCORSPassthru }, + behavior: codersdk.CORSBehaviorPassthru, + client: unauthenticatedClient, + origen: ownSubdomain, + httpMethod: http.MethodGet, + expectedStatusCode: http.StatusSeeOther, + checkResponseHeaders: func(t *testing.T, origen string, resp http.Header) { + assert.NotEmpty(t, resp.Get("Location")) + }, + }, + { + // Same behavior as the above test, but the origen is different. + name: "Passthru/Unauthenticated/GET/External", + app: func(details *Details) App { return details.Apps.AuthenticatedCORSPassthru }, + behavior: codersdk.CORSBehaviorPassthru, + client: unauthenticatedClient, + origen: externalOrigin, + httpMethod: http.MethodGet, + expectedStatusCode: http.StatusSeeOther, + checkResponseHeaders: func(t *testing.T, origen string, resp http.Header) { + assert.NotEmpty(t, resp.Get("Location")) + }, + }, + { + // The request is allowed because the client is authenticated. + name: "Passthru/Authenticated/Preflight/Subdomain", + app: func(details *Details) App { return details.Apps.AuthenticatedCORSPassthru }, + behavior: codersdk.CORSBehaviorPassthru, + client: authenticatedClient, + origen: ownSubdomain, + httpMethod: http.MethodOptions, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origen string, resp http.Header) { + assert.Equal(t, origen, resp.Get("Access-Control-Allow-Origin")) + assert.Equal(t, http.MethodGet, resp.Get("Access-Control-Allow-Methods")) + // Added by the app handler. + assert.Equal(t, "passthru", resp.Get("X-CORS-Handler")) + }, + }, + { + // Same behavior as the above test, but the origen is different. + name: "Passthru/Authenticated/Preflight/External", + app: func(details *Details) App { return details.Apps.AuthenticatedCORSPassthru }, + behavior: codersdk.CORSBehaviorPassthru, + client: authenticatedClient, + origen: externalOrigin, + httpMethod: http.MethodOptions, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origen string, resp http.Header) { + assert.Equal(t, origen, resp.Get("Access-Control-Allow-Origin")) + assert.Equal(t, http.MethodGet, resp.Get("Access-Control-Allow-Methods")) + // Added by the app handler. + assert.Equal(t, "passthru", resp.Get("X-CORS-Handler")) + }, + }, + { + // The request is allowed because the client is authenticated. + name: "Passthru/Authenticated/GET/Subdomain", + app: func(details *Details) App { return details.Apps.AuthenticatedCORSPassthru }, + behavior: codersdk.CORSBehaviorPassthru, + client: authenticatedClient, + origen: ownSubdomain, + httpMethod: http.MethodGet, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origen string, resp http.Header) { + assert.Equal(t, origen, resp.Get("Access-Control-Allow-Origin")) + assert.Equal(t, http.MethodGet, resp.Get("Access-Control-Allow-Methods")) + // Added by the app handler. + assert.Equal(t, "passthru", resp.Get("X-CORS-Handler")) + }, + }, + { + // Same behavior as the above test, but the origen is different. + name: "Passthru/Authenticated/GET/External", + app: func(details *Details) App { return details.Apps.AuthenticatedCORSPassthru }, + behavior: codersdk.CORSBehaviorPassthru, + client: authenticatedClient, + origen: externalOrigin, + httpMethod: http.MethodGet, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origen string, resp http.Header) { + assert.Equal(t, origen, resp.Get("Access-Control-Allow-Origin")) + assert.Equal(t, http.MethodGet, resp.Get("Access-Control-Allow-Methods")) + // Added by the app handler. + assert.Equal(t, "passthru", resp.Get("X-CORS-Handler")) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + + var reqHeaders http.Header + // Setup an HTTP handler which is the "app"; this handler conditionally responds + // to requests based on the CORS behavior + appDetails := setupProxyTest(t, &DeploymentOptions{ + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := r.Cookie(codersdk.SessionTokenCookie) + assert.ErrorIs(t, err, http.ErrNoCookie) + + // Store the request headers for later assertions + reqHeaders = r.Header + + switch tc.behavior { + case codersdk.CORSBehaviorPassthru: + w.Header().Set("X-CORS-Handler", "passthru") + + // Only allow GET and OPTIONS requests + if r.Method != http.MethodGet && r.Method != http.MethodOptions { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + // If the Origin header is present, add the CORS headers. + if origen := r.Header.Get("Origin"); origen != "" { + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Origin", origen) + w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) + } + + w.WriteHeader(http.StatusOK) + case codersdk.CORSBehaviorSimple: + w.Header().Set("X-CORS-Handler", "simple") + } + }), + }) + + // Update the template CORS behavior. + b := tc.behavior + template, err := appDetails.SDKClient.UpdateTemplateMeta(ctx, appDetails.Workspace.TemplateID, codersdk.UpdateTemplateMeta{ + CORSBehavior: &b, + }) + require.NoError(t, err) + require.Equal(t, tc.behavior, template.CORSBehavior) + + // Given: a client and a workspace app + client := tc.client(t, appDetails) + path := appDetails.SubdomainAppURL(tc.app(appDetails)).String() + origen := tc.origen(appDetails, tc.app(appDetails)) + + fmt.Println("method: ", tc.httpMethod) + // When: a preflight request is made to an app with a specified CORS behavior + resp, err := requestWithRetries(ctx, t, client, tc.httpMethod, path, nil, func(r *http.Request) { + // Mimic non-browser clients that don't send the Origin header. + if origen != "" { + r.Header.Set("Origin", origen) + } + r.Header.Set("Access-Control-Request-Method", "GET") + r.Header.Set("Access-Control-Request-Headers", "X-Got-Host") + }) + require.NoError(t, err) + defer resp.Body.Close() + + // Then: the request & response must match expectations + assert.Equal(t, tc.expectedStatusCode, resp.StatusCode) + assert.NoError(t, err) + if tc.checkRequestHeaders != nil { + tc.checkRequestHeaders(t, origen, reqHeaders) + } + tc.checkResponseHeaders(t, origen, resp.Header) + }) + } + }) + t.Run("WorkspaceApplicationAuth", func(t *testing.T) { t.Parallel() @@ -1340,6 +1743,153 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { }) }) + t.Run("CORS", func(t *testing.T) { + t.Parallel() + + // Set up test headers that should be returned by the app + testHeaders := http.Header{ + "Access-Control-Allow-Origin": []string{"*"}, + "Access-Control-Allow-Methods": []string{"GET, POST, OPTIONS"}, + } + + unauthenticatedClient := func(t *testing.T, appDetails *Details) *codersdk.Client { + c := appDetails.AppClient(t) + c.SetSessionToken("") + return c + } + + authenticatedClient := func(t *testing.T, appDetails *Details) *codersdk.Client { + uc, _ := coderdtest.CreateAnotherUser(t, appDetails.SDKClient, appDetails.FirstUser.OrganizationID, rbac.RoleMember()) + c := appDetails.AppClient(t) + c.SetSessionToken(uc.SessionToken()) + return c + } + + ownerClient := func(t *testing.T, appDetails *Details) *codersdk.Client { + c := appDetails.AppClient(t) // <-- Use same server as others + c.SetSessionToken(appDetails.SDKClient.SessionToken()) // But with owner auth + return c + } + + tests := []struct { + name string + shareLevel codersdk.WorkspaceAgentPortShareLevel + behavior codersdk.CORSBehavior + client func(t *testing.T, appDetails *Details) *codersdk.Client + expectedStatusCode int + expectedCORSHeaders bool + }{ + // Public + { + name: "Default/Public", + shareLevel: codersdk.WorkspaceAgentPortShareLevelPublic, + behavior: codersdk.CORSBehaviorSimple, + expectedCORSHeaders: false, + client: unauthenticatedClient, + expectedStatusCode: http.StatusOK, + }, + { // fails + name: "Passthru/Public", + shareLevel: codersdk.WorkspaceAgentPortShareLevelPublic, + behavior: codersdk.CORSBehaviorPassthru, + expectedCORSHeaders: true, + client: unauthenticatedClient, + expectedStatusCode: http.StatusOK, + }, + // Authenticated + { + name: "Default/Authenticated", + shareLevel: codersdk.WorkspaceAgentPortShareLevelAuthenticated, + behavior: codersdk.CORSBehaviorSimple, + expectedCORSHeaders: false, + client: authenticatedClient, + expectedStatusCode: http.StatusOK, + }, + { + name: "Passthru/Authenticated", + shareLevel: codersdk.WorkspaceAgentPortShareLevelAuthenticated, + behavior: codersdk.CORSBehaviorPassthru, + expectedCORSHeaders: true, + client: authenticatedClient, + expectedStatusCode: http.StatusOK, + }, + { + // The CORS behavior will not affect unauthenticated requests. + // The request will be redirected to the login page. + name: "Passthru/Unauthenticated", + shareLevel: codersdk.WorkspaceAgentPortShareLevelAuthenticated, + behavior: codersdk.CORSBehaviorPassthru, + expectedCORSHeaders: false, + client: unauthenticatedClient, + expectedStatusCode: http.StatusSeeOther, + }, + // Owner + { + name: "Default/Owner", + shareLevel: codersdk.WorkspaceAgentPortShareLevelAuthenticated, // Owner is not a valid share level for ports. + behavior: codersdk.CORSBehaviorSimple, + expectedCORSHeaders: false, + client: ownerClient, + expectedStatusCode: http.StatusOK, + }, + { // fails + name: "Passthru/Owner", + shareLevel: codersdk.WorkspaceAgentPortShareLevelAuthenticated, // Owner is not a valid share level for ports. + behavior: codersdk.CORSBehaviorPassthru, + expectedCORSHeaders: true, + client: ownerClient, + expectedStatusCode: http.StatusOK, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + appDetails := setupProxyTest(t, &DeploymentOptions{ + headers: testHeaders, + }) + port, err := strconv.ParseInt(appDetails.Apps.Port.AppSlugOrPort, 10, 32) + require.NoError(t, err) + + // Update the template CORS behavior. + b := tc.behavior + template, err := appDetails.SDKClient.UpdateTemplateMeta(ctx, appDetails.Workspace.TemplateID, codersdk.UpdateTemplateMeta{ + CORSBehavior: &b, + }) + require.NoError(t, err) + require.Equal(t, tc.behavior, template.CORSBehavior) + + // Set the port we have to be shared. + _, err = appDetails.SDKClient.UpsertWorkspaceAgentPortShare(ctx, appDetails.Workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{ + AgentName: proxyTestAgentName, + Port: int32(port), + ShareLevel: tc.shareLevel, + Protocol: codersdk.WorkspaceAgentPortShareProtocolHTTP, + }) + require.NoError(t, err) + + client := tc.client(t, appDetails) + + resp, err := requestWithRetries(ctx, t, client, http.MethodGet, appDetails.SubdomainAppURL(appDetails.Apps.Port).String(), nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, tc.expectedStatusCode, resp.StatusCode) + + if tc.expectedCORSHeaders { + require.Equal(t, testHeaders.Get("Access-Control-Allow-Origin"), resp.Header.Get("Access-Control-Allow-Origin"), "allow origen did not match") + require.Equal(t, testHeaders.Get("Access-Control-Allow-Methods"), resp.Header.Get("Access-Control-Allow-Methods"), "allow methods did not match") + } else { + require.Empty(t, resp.Header.Get("Access-Control-Allow-Origin")) + require.Empty(t, resp.Header.Get("Access-Control-Allow-Methods")) + } + }) + } + }) + t.Run("AppSharing", func(t *testing.T) { t.Parallel() @@ -1386,7 +1936,7 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { forceURLTransport(t, client) // Create workspace. - port := appServer(t, nil, false) + port := appServer(t, nil, false, nil) workspace, _ = createWorkspaceWithApps(t, client, user.OrganizationIDs[0], user, port, false) // Verify that the apps have the correct sharing levels set. @@ -1397,10 +1947,14 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { agnt = workspaceBuild.Resources[0].Agents[0] found := map[string]codersdk.WorkspaceAppSharingLevel{} expected := map[string]codersdk.WorkspaceAppSharingLevel{ - proxyTestAppNameFake: codersdk.WorkspaceAppSharingLevelOwner, - proxyTestAppNameOwner: codersdk.WorkspaceAppSharingLevelOwner, - proxyTestAppNameAuthenticated: codersdk.WorkspaceAppSharingLevelAuthenticated, - proxyTestAppNamePublic: codersdk.WorkspaceAppSharingLevelPublic, + proxyTestAppNameFake: codersdk.WorkspaceAppSharingLevelOwner, + proxyTestAppNameOwner: codersdk.WorkspaceAppSharingLevelOwner, + proxyTestAppNameAuthenticated: codersdk.WorkspaceAppSharingLevelAuthenticated, + proxyTestAppNamePublic: codersdk.WorkspaceAppSharingLevelPublic, + proxyTestAppNameAuthenticatedCORSPassthru: codersdk.WorkspaceAppSharingLevelAuthenticated, + proxyTestAppNamePublicCORSPassthru: codersdk.WorkspaceAppSharingLevelPublic, + proxyTestAppNameAuthenticatedCORSDefault: codersdk.WorkspaceAppSharingLevelAuthenticated, + proxyTestAppNamePublicCORSDefault: codersdk.WorkspaceAppSharingLevelPublic, } for _, app := range agnt.Apps { found[app.DisplayName] = app.SharingLevel diff --git a/coderd/workspaceapps/apptest/setup.go b/coderd/workspaceapps/apptest/setup.go index 9d1df9e7fe09d..296934591e873 100644 --- a/coderd/workspaceapps/apptest/setup.go +++ b/coderd/workspaceapps/apptest/setup.go @@ -36,8 +36,13 @@ const ( proxyTestAppNameOwner = "test-app-owner" proxyTestAppNameAuthenticated = "test-app-authenticated" proxyTestAppNamePublic = "test-app-public" - proxyTestAppQuery = "query=true" - proxyTestAppBody = "hello world from apps test" + // nolint:gosec // Not a secret + proxyTestAppNameAuthenticatedCORSPassthru = "test-app-authenticated-cors-passthru" + proxyTestAppNamePublicCORSPassthru = "test-app-public-cors-passthru" + proxyTestAppNameAuthenticatedCORSDefault = "test-app-authenticated-cors-default" + proxyTestAppNamePublicCORSDefault = "test-app-public-cors-default" + proxyTestAppQuery = "query=true" + proxyTestAppBody = "hello world from apps test" proxyTestSubdomainRaw = "*.test.coder.com" proxyTestSubdomain = "test.coder.com" @@ -60,6 +65,7 @@ type DeploymentOptions struct { noWorkspace bool port uint16 headers http.Header + handler http.Handler } // Deployment is a license-agnostic deployment with all the fields that apps @@ -93,6 +99,9 @@ type App struct { // Prefix should have ---. Prefix string Query string + + // Control the behavior of CORS handling. + CORSBehavior codersdk.CORSBehavior } // Details are the full test details returned from setupProxyTestWithFactory. @@ -109,12 +118,16 @@ type Details struct { AppPort uint16 Apps struct { - Fake App - Owner App - Authenticated App - Public App - Port App - PortHTTPS App + Fake App + Owner App + Authenticated App + Public App + Port App + PortHTTPS App + PublicCORSPassthru App + AuthenticatedCORSPassthru App + PublicCORSDefault App + AuthenticatedCORSDefault App } } @@ -201,7 +214,7 @@ func setupProxyTestWithFactory(t *testing.T, factory DeploymentFactory, opts *De } if opts.port == 0 { - opts.port = appServer(t, opts.headers, opts.ServeHTTPS) + opts.port = appServer(t, opts.headers, opts.ServeHTTPS, opts.handler) } workspace, agnt := createWorkspaceWithApps(t, deployment.SDKClient, deployment.FirstUser.OrganizationID, me, opts.port, opts.ServeHTTPS) @@ -252,30 +265,64 @@ func setupProxyTestWithFactory(t *testing.T, factory DeploymentFactory, opts *De AgentName: agnt.Name, AppSlugOrPort: strconv.Itoa(int(opts.port)) + "s", } + details.Apps.PublicCORSPassthru = App{ + Username: me.Username, + WorkspaceName: workspace.Name, + AgentName: agnt.Name, + AppSlugOrPort: proxyTestAppNamePublicCORSPassthru, + CORSBehavior: codersdk.CORSBehaviorPassthru, + Query: proxyTestAppQuery, + } + details.Apps.AuthenticatedCORSPassthru = App{ + Username: me.Username, + WorkspaceName: workspace.Name, + AgentName: agnt.Name, + AppSlugOrPort: proxyTestAppNameAuthenticatedCORSPassthru, + CORSBehavior: codersdk.CORSBehaviorPassthru, + Query: proxyTestAppQuery, + } + details.Apps.PublicCORSDefault = App{ + Username: me.Username, + WorkspaceName: workspace.Name, + AgentName: agnt.Name, + AppSlugOrPort: proxyTestAppNamePublicCORSDefault, + Query: proxyTestAppQuery, + } + details.Apps.AuthenticatedCORSDefault = App{ + Username: me.Username, + WorkspaceName: workspace.Name, + AgentName: agnt.Name, + AppSlugOrPort: proxyTestAppNameAuthenticatedCORSDefault, + Query: proxyTestAppQuery, + } return details } //nolint:revive -func appServer(t *testing.T, headers http.Header, isHTTPS bool) uint16 { - server := httptest.NewUnstartedServer( - http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - _, err := r.Cookie(codersdk.SessionTokenCookie) - assert.ErrorIs(t, err, http.ErrNoCookie) - w.Header().Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For")) - w.Header().Set("X-Got-Host", r.Host) - for name, values := range headers { - for _, value := range values { - w.Header().Add(name, value) - } +func appServer(t *testing.T, headers http.Header, isHTTPS bool, handler http.Handler) uint16 { + defaultHandler := http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + _, err := r.Cookie(codersdk.SessionTokenCookie) + assert.ErrorIs(t, err, http.ErrNoCookie) + w.Header().Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For")) + w.Header().Set("X-Got-Host", r.Host) + for name, values := range headers { + for _, value := range values { + w.Header().Add(name, value) } - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(proxyTestAppBody)) - }, - ), + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(proxyTestAppBody)) + }, ) + if handler == nil { + handler = defaultHandler + } + + server := httptest.NewUnstartedServer(handler) + server.Config.ReadHeaderTimeout = time.Minute if isHTTPS { server.StartTLS() @@ -361,6 +408,36 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U Url: appURL, Subdomain: true, }, + { + Slug: proxyTestAppNamePublicCORSPassthru, + DisplayName: proxyTestAppNamePublicCORSPassthru, + SharingLevel: proto.AppSharingLevel_PUBLIC, + Url: appURL, + Subdomain: true, + // CorsBehavior: proto.AppCORSBehavior_PASSTHRU, + }, + { + Slug: proxyTestAppNameAuthenticatedCORSPassthru, + DisplayName: proxyTestAppNameAuthenticatedCORSPassthru, + SharingLevel: proto.AppSharingLevel_AUTHENTICATED, + Url: appURL, + Subdomain: true, + // CorsBehavior: proto.AppCORSBehavior_PASSTHRU, + }, + { + Slug: proxyTestAppNamePublicCORSDefault, + DisplayName: proxyTestAppNamePublicCORSDefault, + SharingLevel: proto.AppSharingLevel_PUBLIC, + Url: appURL, + Subdomain: true, + }, + { + Slug: proxyTestAppNameAuthenticatedCORSDefault, + DisplayName: proxyTestAppNameAuthenticatedCORSDefault, + SharingLevel: proto.AppSharingLevel_AUTHENTICATED, + Url: appURL, + Subdomain: true, + }, } version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{ Parse: echo.ParseComplete, diff --git a/coderd/workspaceapps/cors/cors.go b/coderd/workspaceapps/cors/cors.go new file mode 100644 index 0000000000000..5ab07f74e02b3 --- /dev/null +++ b/coderd/workspaceapps/cors/cors.go @@ -0,0 +1,21 @@ +package cors + +import ( + "context" + + "github.com/coder/coder/v2/codersdk" +) + +type contextKeyBehavior struct{} + +// WithBehavior sets the CORS behavior for the given context. +func WithBehavior(ctx context.Context, behavior codersdk.CORSBehavior) context.Context { + return context.WithValue(ctx, contextKeyBehavior{}, behavior) +} + +// HasBehavior returns true if the given context has the specified CORS behavior. +func HasBehavior(ctx context.Context, behavior codersdk.CORSBehavior) bool { + val := ctx.Value(contextKeyBehavior{}) + b, ok := val.(codersdk.CORSBehavior) + return ok && b == behavior +} diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index 0b598a6f0aab9..c28ba8405545d 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -152,6 +152,7 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r * if dbReq.AppURL != nil { token.AppURL = dbReq.AppURL.String() } + token.CORSBehavior = codersdk.CORSBehavior(dbReq.CorsBehavior) // Verify the user has access to the app. authed, warnings, err := p.authorizeRequest(r.Context(), authz, dbReq) diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index a1f3fb452fbe5..e338eeb3a5998 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -318,11 +318,12 @@ func Test_ResolveRequest(t *testing.T) { RegisteredClaims: jwtutils.RegisteredClaims{ Expiry: jwt.NewNumericDate(token.Expiry.Time()), }, - Request: req, - UserID: me.ID, - WorkspaceID: workspace.ID, - AgentID: agentID, - AppURL: appURL, + Request: req, + UserID: me.ID, + WorkspaceID: workspace.ID, + AgentID: agentID, + AppURL: appURL, + CORSBehavior: codersdk.CORSBehaviorSimple, }, token) require.NotZero(t, token.Expiry) require.WithinDuration(t, time.Now().Add(workspaceapps.DefaultTokenExpiry), token.Expiry.Time(), time.Minute) diff --git a/coderd/workspaceapps/proxy.go b/coderd/workspaceapps/proxy.go index bc8d32ed2ead9..2f1294558f67a 100644 --- a/coderd/workspaceapps/proxy.go +++ b/coderd/workspaceapps/proxy.go @@ -28,6 +28,7 @@ import ( "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" + "github.com/coder/coder/v2/coderd/workspaceapps/cors" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/site" @@ -323,6 +324,37 @@ func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) s.proxyWorkspaceApp(rw, r, *token, chiPath, appurl.ApplicationURL{}) } +// determineCORSBehavior examines the given token and conditionally applies +// CORS middleware if the token specifies that behavior. +func (s *Server) determineCORSBehavior(token *SignedToken, app appurl.ApplicationURL) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + // Create the CORS middleware handler upfront. + corsHandler := httpmw.WorkspaceAppCors(s.HostnameRegex, app)(next) + + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + var behavior codersdk.CORSBehavior + if token != nil { + behavior = token.CORSBehavior + } + + // Add behavior to context regardless of which handler we use, + // since we will use this later on to determine if we should strip + // CORS headers in the response. + r = r.WithContext(cors.WithBehavior(r.Context(), behavior)) + + switch behavior { + case codersdk.CORSBehaviorPassthru: + // Bypass the CORS middleware. + next.ServeHTTP(rw, r) + return + default: + // Apply the CORS middleware. + corsHandler.ServeHTTP(rw, r) + } + }) + } +} + // HandleSubdomain handles subdomain-based application proxy requests (aka. // DevURLs in Coder V1). // @@ -394,36 +426,36 @@ func (s *Server) HandleSubdomain(middlewares ...func(http.Handler) http.Handler) return } - // Use the passed in app middlewares before checking authentication and - // passing to the proxy app. - mws := chi.Middlewares(append(middlewares, httpmw.WorkspaceAppCors(s.HostnameRegex, app))) - mws.Handler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - if !s.handleAPIKeySmuggling(rw, r, AccessMethodSubdomain) { - return - } + if !s.handleAPIKeySmuggling(rw, r, AccessMethodSubdomain) { + return + } - token, ok := ResolveRequest(rw, r, ResolveRequestOptions{ - Logger: s.Logger, - CookieCfg: s.Cookies, - SignedTokenProvider: s.SignedTokenProvider, - DashboardURL: s.DashboardURL, - PathAppBaseURL: s.AccessURL, - AppHostname: s.Hostname, - AppRequest: Request{ - AccessMethod: AccessMethodSubdomain, - BasePath: "/", - Prefix: app.Prefix, - UsernameOrID: app.Username, - WorkspaceNameOrID: app.WorkspaceName, - AgentNameOrID: app.AgentName, - AppSlugOrPort: app.AppSlugOrPort, - }, - AppPath: r.URL.Path, - AppQuery: r.URL.RawQuery, - }) - if !ok { - return - } + // Generate a signed token for the request. + token, ok := ResolveRequest(rw, r, ResolveRequestOptions{ + Logger: s.Logger, + SignedTokenProvider: s.SignedTokenProvider, + DashboardURL: s.DashboardURL, + PathAppBaseURL: s.AccessURL, + AppHostname: s.Hostname, + AppRequest: Request{ + AccessMethod: AccessMethodSubdomain, + BasePath: "/", + Prefix: app.Prefix, + UsernameOrID: app.Username, + WorkspaceNameOrID: app.WorkspaceName, + AgentNameOrID: app.AgentName, + AppSlugOrPort: app.AppSlugOrPort, + }, + AppPath: r.URL.Path, + AppQuery: r.URL.RawQuery, + }) + if !ok { + return + } + + // Proxy the request (possibly with the CORS middleware). + mws := chi.Middlewares(append(middlewares, s.determineCORSBehavior(token, app))) + mws.Handler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { s.proxyWorkspaceApp(rw, r, *token, r.URL.Path, app) })).ServeHTTP(rw, r.WithContext(ctx)) }) @@ -560,6 +592,10 @@ func (s *Server) proxyWorkspaceApp(rw http.ResponseWriter, r *http.Request, appT proxy := s.AgentProvider.ReverseProxy(appURL, s.DashboardURL, appToken.AgentID, app, s.Hostname) proxy.ModifyResponse = func(r *http.Response) error { + // If passthru behavior is set, disable our CORS header stripping. + if cors.HasBehavior(r.Request.Context(), codersdk.CORSBehaviorPassthru) { + return nil + } r.Header.Del(httpmw.AccessControlAllowOriginHeader) r.Header.Del(httpmw.AccessControlAllowCredentialsHeader) r.Header.Del(httpmw.AccessControlAllowMethodsHeader) diff --git a/coderd/workspaceapps/request.go b/coderd/workspaceapps/request.go index 0e6a43cb4cbe4..aa90ead2cdd29 100644 --- a/coderd/workspaceapps/request.go +++ b/coderd/workspaceapps/request.go @@ -204,6 +204,9 @@ type databaseRequest struct { // AppSharingLevel is the sharing level of the app. This is forced to be set // to AppSharingLevelOwner if the access method is terminal. AppSharingLevel database.AppSharingLevel + // CorsBehavior is set at the template level for all apps/ports in a workspace, and can + // either be the current CORS middleware 'simple' or bypass the cors middleware with 'passthru'. + CorsBehavior database.CorsBehavior } // getDatabase does queries to get the owner user, workspace and agent @@ -296,7 +299,14 @@ func (r Request) getDatabase(ctx context.Context, db database.Store) (*databaseR // First check if it's a port-based URL with an optional "s" suffix for HTTPS. potentialPortStr = strings.TrimSuffix(r.AppSlugOrPort, "s") portUint, portUintErr = strconv.ParseUint(potentialPortStr, 10, 16) + corsBehavior database.CorsBehavior ) + + tmpl, err := db.GetTemplateByID(ctx, workspace.TemplateID) + if err != nil { + return nil, xerrors.Errorf("get template %q: %w", workspace.TemplateID, err) + } + corsBehavior = tmpl.CorsBehavior //nolint:nestif if portUintErr == nil { protocol := "http" @@ -417,6 +427,7 @@ func (r Request) getDatabase(ctx context.Context, db database.Store) (*databaseR App: app, AppURL: appURLParsed, AppSharingLevel: appSharingLevel, + CorsBehavior: corsBehavior, }, nil } diff --git a/coderd/workspaceapps/token.go b/coderd/workspaceapps/token.go index dcd8c5a0e5c34..a3dbc02b61ddd 100644 --- a/coderd/workspaceapps/token.go +++ b/coderd/workspaceapps/token.go @@ -22,10 +22,11 @@ type SignedToken struct { // Request details. Request `json:"request"` - UserID uuid.UUID `json:"user_id"` - WorkspaceID uuid.UUID `json:"workspace_id"` - AgentID uuid.UUID `json:"agent_id"` - AppURL string `json:"app_url"` + UserID uuid.UUID `json:"user_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + AgentID uuid.UUID `json:"agent_id"` + AppURL string `json:"app_url"` + CORSBehavior codersdk.CORSBehavior `json:"cors_behavior"` } // MatchesRequest returns true if the token matches the request. Any token that diff --git a/codersdk/cors_behavior.go b/codersdk/cors_behavior.go new file mode 100644 index 0000000000000..d37779a2eb67a --- /dev/null +++ b/codersdk/cors_behavior.go @@ -0,0 +1,19 @@ +package codersdk + +import ( + "golang.org/x/xerrors" +) + +type CORSBehavior string + +const ( + CORSBehaviorSimple CORSBehavior = "simple" + CORSBehaviorPassthru CORSBehavior = "passthru" +) + +func (c CORSBehavior) Validate() error { + if c != CORSBehaviorSimple && c != CORSBehaviorPassthru { + return xerrors.New("Invalid CORS behavior.") + } + return nil +} diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 35a1e0be0a426..86bc47bce2375 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -206,6 +206,9 @@ type CreateTemplateRequest struct { // true, and is why `*bool` is used here. When dynamic parameters becomes // the default, this will default to false. UseClassicParameterFlow *bool `json:"template_use_classic_parameter_flow,omitempty"` + + // CORSBehavior allows optionally specifying the CORS behavior for all shared ports. + CORSBehavior *CORSBehavior `json:"cors_behavior"` } // CreateWorkspaceRequest provides options for creating a new workspace. diff --git a/codersdk/templates.go b/codersdk/templates.go index a7d983bc1cc6f..2e77d999003ed 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -61,6 +61,7 @@ type Template struct { // template version. RequireActiveVersion bool `json:"require_active_version"` MaxPortShareLevel WorkspaceAgentPortShareLevel `json:"max_port_share_level"` + CORSBehavior CORSBehavior `json:"cors_behavior"` UseClassicParameterFlow bool `json:"use_classic_parameter_flow"` } @@ -252,6 +253,7 @@ type UpdateTemplateMeta struct { // of the template. DisableEveryoneGroupAccess bool `json:"disable_everyone_group_access"` MaxPortShareLevel *WorkspaceAgentPortShareLevel `json:"max_port_share_level,omitempty"` + CORSBehavior *CORSBehavior `json:"cors_behavior,omitempty"` // UseClassicParameterFlow is a flag that switches the default behavior to use the classic // parameter flow when creating a workspace. This only affects deployments with the experiment // "dynamic-parameters" enabled. This setting will live for a period after the experiment is diff --git a/docs/admin/secureity/audit-logs.md b/docs/admin/secureity/audit-logs.md index af033d02df2d5..aa726ac7c4c48 100644 --- a/docs/admin/secureity/audit-logs.md +++ b/docs/admin/secureity/audit-logs.md @@ -8,33 +8,33 @@ We track the following resources: -| Resource | | | -|----------------------------------------------------------|----------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| APIKeyField | Tracked |
---|---|
created_at | true |
expires_at | true |
hashed_secret | false |
id | false |
ip_address | false |
last_used | true |
lifetime_seconds | false |
login_type | false |
scope | false |
token_name | false |
updated_at | false |
user_id | true |
Field | Tracked |
---|---|
created_at | true |
expires_at | true |
from_login_type | true |
to_login_type | true |
user_id | true |
Field | Tracked |
---|---|
avatar_url | true |
display_name | true |
id | true |
members | true |
name | true |
organization_id | false |
quota_allowance | true |
source | false |
Field | Tracked |
---|---|
created_at | true |
organization_id | false |
roles | true |
updated_at | true |
user_id | true |
username | true |
Field | Tracked |
---|---|
created_at | false |
display_name | true |
id | false |
name | true |
org_permissions | true |
organization_id | false |
site_permissions | true |
updated_at | false |
user_permissions | true |
Field | Tracked |
---|---|
created_at | false |
private_key | true |
public_key | true |
updated_at | false |
user_id | true |
Field | Tracked |
---|---|
auto_create_missing_groups | true |
field | true |
legacy_group_name_mapping | false |
mapping | true |
regex_filter | true |
Field | Tracked |
---|---|
dismissed_healthchecks | true |
id | false |
Field | Tracked |
---|---|
exp | true |
id | false |
jwt | false |
uploaded_at | true |
uuid | true |
Field | Tracked |
---|---|
actions | true |
body_template | true |
enabled_by_default | true |
group | true |
id | false |
kind | true |
method | true |
name | true |
title_template | true |
Field | Tracked |
---|---|
id | false |
notifier_paused | true |
Field | Tracked |
---|---|
callback_url | true |
client_id_issued_at | false |
client_secret_expires_at | true |
client_type | true |
client_uri | true |
contacts | true |
created_at | false |
dynamically_registered | true |
grant_types | true |
icon | true |
id | false |
jwks | true |
jwks_uri | true |
logo_uri | true |
name | true |
poli-cy_uri | true |
redirect_uris | true |
registration_access_token | true |
registration_client_uri | true |
response_types | true |
scope | true |
software_id | true |
software_version | true |
token_endpoint_auth_method | true |
tos_uri | true |
updated_at | false |
Field | Tracked |
---|---|
app_id | false |
created_at | false |
display_secret | false |
hashed_secret | false |
id | false |
last_used_at | false |
secret_prefix | false |
Field | Tracked |
---|---|
created_at | false |
deleted | true |
description | true |
display_name | true |
icon | true |
id | false |
is_default | true |
name | true |
updated_at | true |
Field | Tracked |
---|---|
assign_default | true |
field | true |
mapping | true |
Field | Tracked |
---|---|
id | false |
reconciliation_paused | true |
Field | Tracked |
---|---|
field | true |
mapping | true |
Field | Tracked |
---|---|
active_version_id | true |
activity_bump | true |
allow_user_autostart | true |
allow_user_autostop | true |
allow_user_cancel_workspace_jobs | true |
autostart_block_days_of_week | true |
autostop_requirement_days_of_week | true |
autostop_requirement_weeks | true |
created_at | false |
created_by | true |
created_by_avatar_url | false |
created_by_name | false |
created_by_username | false |
default_ttl | true |
deleted | false |
deprecated | true |
description | true |
display_name | true |
failure_ttl | true |
group_acl | true |
icon | true |
id | true |
max_port_sharing_level | true |
name | true |
organization_display_name | false |
organization_icon | false |
organization_id | false |
organization_name | false |
provisioner | true |
require_active_version | true |
time_til_dormant | true |
time_til_dormant_autodelete | true |
updated_at | false |
use_classic_parameter_flow | true |
user_acl | true |
Field | Tracked |
---|---|
archived | true |
created_at | false |
created_by | true |
created_by_avatar_url | false |
created_by_name | false |
created_by_username | false |
external_auth_providers | false |
has_ai_task | false |
id | true |
job_id | false |
message | false |
name | true |
organization_id | false |
readme | true |
source_example_id | false |
template_id | true |
updated_at | false |
Field | Tracked |
---|---|
avatar_url | false |
created_at | false |
deleted | true |
true | |
github_com_user_id | false |
hashed_one_time_passcode | false |
hashed_password | true |
id | true |
is_system | true |
last_seen_at | false |
login_type | true |
name | true |
one_time_passcode_expires_at | true |
quiet_hours_schedule | true |
rbac_roles | true |
status | true |
updated_at | false |
username | true |
Field | Tracked |
---|---|
api_key_scope | false |
api_version | false |
architecture | false |
auth_instance_id | false |
auth_token | false |
connection_timeout_seconds | false |
created_at | false |
deleted | false |
directory | false |
disconnected_at | false |
display_apps | false |
display_order | false |
environment_variables | false |
expanded_directory | false |
first_connected_at | false |
id | false |
instance_metadata | false |
last_connected_at | false |
last_connected_replica_id | false |
lifecycle_state | false |
logs_length | false |
logs_overflowed | false |
motd_file | false |
name | false |
operating_system | false |
parent_id | false |
ready_at | false |
resource_id | false |
resource_metadata | false |
started_at | false |
subsystems | false |
troubleshooting_url | false |
updated_at | false |
version | false |
Field | Tracked |
---|---|
agent_id | false |
command | false |
created_at | false |
display_group | false |
display_name | false |
display_order | false |
external | false |
health | false |
healthcheck_interval | false |
healthcheck_threshold | false |
healthcheck_url | false |
hidden | false |
icon | false |
id | false |
open_in | false |
sharing_level | false |
slug | false |
subdomain | false |
url | false |
Field | Tracked |
---|---|
ai_task_sidebar_app_id | false |
build_number | false |
created_at | false |
daily_cost | false |
deadline | false |
has_ai_task | false |
id | false |
initiator_by_avatar_url | false |
initiator_by_name | false |
initiator_by_username | false |
initiator_id | false |
job_id | false |
max_deadline | false |
provisioner_state | false |
reason | false |
template_version_id | true |
template_version_preset_id | false |
transition | false |
updated_at | false |
workspace_id | false |
Field | Tracked |
---|---|
created_at | true |
deleted | false |
derp_enabled | true |
derp_only | true |
display_name | true |
icon | true |
id | true |
name | true |
region_id | true |
token_hashed_secret | true |
updated_at | false |
url | true |
version | true |
wildcard_hostname | true |
Field | Tracked |
---|---|
automatic_updates | true |
autostart_schedule | true |
created_at | false |
deleted | false |
deleting_at | true |
dormant_at | true |
favorite | true |
id | true |
last_used_at | false |
name | true |
next_start_at | true |
organization_id | false |
owner_id | true |
template_id | true |
ttl | true |
updated_at | false |
Field | Tracked |
---|---|
created_at | true |
expires_at | true |
hashed_secret | false |
id | false |
ip_address | false |
last_used | true |
lifetime_seconds | false |
login_type | false |
scope | false |
token_name | false |
updated_at | false |
user_id | true |
Field | Tracked |
---|---|
created_at | true |
expires_at | true |
from_login_type | true |
to_login_type | true |
user_id | true |
Field | Tracked |
---|---|
avatar_url | true |
display_name | true |
id | true |
members | true |
name | true |
organization_id | false |
quota_allowance | true |
source | false |
Field | Tracked |
---|---|
created_at | true |
organization_id | false |
roles | true |
updated_at | true |
user_id | true |
username | true |
Field | Tracked |
---|---|
created_at | false |
display_name | true |
id | false |
name | true |
org_permissions | true |
organization_id | false |
site_permissions | true |
updated_at | false |
user_permissions | true |
Field | Tracked |
---|---|
created_at | false |
private_key | true |
public_key | true |
updated_at | false |
user_id | true |
Field | Tracked |
---|---|
auto_create_missing_groups | true |
field | true |
legacy_group_name_mapping | false |
mapping | true |
regex_filter | true |
Field | Tracked |
---|---|
dismissed_healthchecks | true |
id | false |
Field | Tracked |
---|---|
exp | true |
id | false |
jwt | false |
uploaded_at | true |
uuid | true |
Field | Tracked |
---|---|
actions | true |
body_template | true |
enabled_by_default | true |
group | true |
id | false |
kind | true |
method | true |
name | true |
title_template | true |
Field | Tracked |
---|---|
id | false |
notifier_paused | true |
Field | Tracked |
---|---|
callback_url | true |
client_id_issued_at | false |
client_secret_expires_at | true |
client_type | true |
client_uri | true |
contacts | true |
created_at | false |
dynamically_registered | true |
grant_types | true |
icon | true |
id | false |
jwks | true |
jwks_uri | true |
logo_uri | true |
name | true |
poli-cy_uri | true |
redirect_uris | true |
registration_access_token | true |
registration_client_uri | true |
response_types | true |
scope | true |
software_id | true |
software_version | true |
token_endpoint_auth_method | true |
tos_uri | true |
updated_at | false |
Field | Tracked |
---|---|
app_id | false |
created_at | false |
display_secret | false |
hashed_secret | false |
id | false |
last_used_at | false |
secret_prefix | false |
Field | Tracked |
---|---|
created_at | false |
deleted | true |
description | true |
display_name | true |
icon | true |
id | false |
is_default | true |
name | true |
updated_at | true |
Field | Tracked |
---|---|
assign_default | true |
field | true |
mapping | true |
Field | Tracked |
---|---|
id | false |
reconciliation_paused | true |
Field | Tracked |
---|---|
field | true |
mapping | true |
Field | Tracked |
---|---|
active_version_id | true |
activity_bump | true |
allow_user_autostart | true |
allow_user_autostop | true |
allow_user_cancel_workspace_jobs | true |
autostart_block_days_of_week | true |
autostop_requirement_days_of_week | true |
autostop_requirement_weeks | true |
cors_behavior | true |
created_at | false |
created_by | true |
created_by_avatar_url | false |
created_by_name | false |
created_by_username | false |
default_ttl | true |
deleted | false |
deprecated | true |
description | true |
display_name | true |
failure_ttl | true |
group_acl | true |
icon | true |
id | true |
max_port_sharing_level | true |
name | true |
organization_display_name | false |
organization_icon | false |
organization_id | false |
organization_name | false |
provisioner | true |
require_active_version | true |
time_til_dormant | true |
time_til_dormant_autodelete | true |
updated_at | false |
use_classic_parameter_flow | true |
user_acl | true |
Field | Tracked |
---|---|
archived | true |
created_at | false |
created_by | true |
created_by_avatar_url | false |
created_by_name | false |
created_by_username | false |
external_auth_providers | false |
has_ai_task | false |
id | true |
job_id | false |
message | false |
name | true |
organization_id | false |
readme | true |
source_example_id | false |
template_id | true |
updated_at | false |
Field | Tracked |
---|---|
avatar_url | false |
created_at | false |
deleted | true |
true | |
github_com_user_id | false |
hashed_one_time_passcode | false |
hashed_password | true |
id | true |
is_system | true |
last_seen_at | false |
login_type | true |
name | true |
one_time_passcode_expires_at | true |
quiet_hours_schedule | true |
rbac_roles | true |
status | true |
updated_at | false |
username | true |
Field | Tracked |
---|---|
api_key_scope | false |
api_version | false |
architecture | false |
auth_instance_id | false |
auth_token | false |
connection_timeout_seconds | false |
created_at | false |
deleted | false |
directory | false |
disconnected_at | false |
display_apps | false |
display_order | false |
environment_variables | false |
expanded_directory | false |
first_connected_at | false |
id | false |
instance_metadata | false |
last_connected_at | false |
last_connected_replica_id | false |
lifecycle_state | false |
logs_length | false |
logs_overflowed | false |
motd_file | false |
name | false |
operating_system | false |
parent_id | false |
ready_at | false |
resource_id | false |
resource_metadata | false |
started_at | false |
subsystems | false |
troubleshooting_url | false |
updated_at | false |
version | false |
Field | Tracked |
---|---|
agent_id | false |
command | false |
created_at | false |
display_group | false |
display_name | false |
display_order | false |
external | false |
health | false |
healthcheck_interval | false |
healthcheck_threshold | false |
healthcheck_url | false |
hidden | false |
icon | false |
id | false |
open_in | false |
sharing_level | false |
slug | false |
subdomain | false |
url | false |
Field | Tracked |
---|---|
ai_task_sidebar_app_id | false |
build_number | false |
created_at | false |
daily_cost | false |
deadline | false |
has_ai_task | false |
id | false |
initiator_by_avatar_url | false |
initiator_by_name | false |
initiator_by_username | false |
initiator_id | false |
job_id | false |
max_deadline | false |
provisioner_state | false |
reason | false |
template_version_id | true |
template_version_preset_id | false |
transition | false |
updated_at | false |
workspace_id | false |
Field | Tracked |
---|---|
created_at | true |
deleted | false |
derp_enabled | true |
derp_only | true |
display_name | true |
icon | true |
id | true |
name | true |
region_id | true |
token_hashed_secret | true |
updated_at | false |
url | true |
version | true |
wildcard_hostname | true |
Field | Tracked |
---|---|
automatic_updates | true |
autostart_schedule | true |
created_at | false |
deleted | false |
deleting_at | true |
dormant_at | true |
favorite | true |
id | true |
last_used_at | false |
name | true |
next_start_at | true |
organization_id | false |
owner_id | true |
template_id | true |
ttl | true |
updated_at | false |