mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add API key scope to restrict access to user data (#17692)
This commit is contained in:
+6
-4
@@ -802,6 +802,11 @@ func New(options *Options) *API {
|
||||
PostAuthAdditionalHeadersFunc: options.PostAuthAdditionalHeadersFunc,
|
||||
})
|
||||
|
||||
workspaceAgentInfo := httpmw.ExtractWorkspaceAgentAndLatestBuild(httpmw.ExtractWorkspaceAgentAndLatestBuildConfig{
|
||||
DB: options.Database,
|
||||
Optional: false,
|
||||
})
|
||||
|
||||
// API rate limit middleware. The counter is local and not shared between
|
||||
// replicas or instances of this middleware.
|
||||
apiRateLimiter := httpmw.RateLimit(options.APIRateLimit, time.Minute)
|
||||
@@ -1289,10 +1294,7 @@ func New(options *Options) *API {
|
||||
httpmw.RequireAPIKeyOrWorkspaceProxyAuth(),
|
||||
).Get("/connection", api.workspaceAgentConnectionGeneric)
|
||||
r.Route("/me", func(r chi.Router) {
|
||||
r.Use(httpmw.ExtractWorkspaceAgentAndLatestBuild(httpmw.ExtractWorkspaceAgentAndLatestBuildConfig{
|
||||
DB: options.Database,
|
||||
Optional: false,
|
||||
}))
|
||||
r.Use(workspaceAgentInfo)
|
||||
r.Get("/rpc", api.workspaceAgentRPC)
|
||||
r.Patch("/logs", api.patchWorkspaceAgentLogs)
|
||||
r.Patch("/app-status", api.patchWorkspaceAgentAppStatus)
|
||||
|
||||
@@ -4018,8 +4018,9 @@ func (s *MethodTestSuite) TestSystemFunctions() {
|
||||
s.Run("InsertWorkspaceAgent", s.Subtest(func(db database.Store, check *expects) {
|
||||
dbtestutil.DisableForeignKeysAndTriggers(s.T(), db)
|
||||
check.Args(database.InsertWorkspaceAgentParams{
|
||||
ID: uuid.New(),
|
||||
Name: "dev",
|
||||
ID: uuid.New(),
|
||||
Name: "dev",
|
||||
APIKeyScope: database.AgentKeyScopeEnumAll,
|
||||
}).Asserts(rbac.ResourceSystem, policy.ActionCreate)
|
||||
}))
|
||||
s.Run("InsertWorkspaceApp", s.Subtest(func(db database.Store, check *expects) {
|
||||
|
||||
@@ -212,6 +212,7 @@ func WorkspaceAgent(t testing.TB, db database.Store, orig database.WorkspaceAgen
|
||||
MOTDFile: takeFirst(orig.TroubleshootingURL, ""),
|
||||
DisplayApps: append([]database.DisplayApp{}, orig.DisplayApps...),
|
||||
DisplayOrder: takeFirst(orig.DisplayOrder, 1),
|
||||
APIKeyScope: takeFirst(orig.APIKeyScope, database.AgentKeyScopeEnumAll),
|
||||
})
|
||||
require.NoError(t, err, "insert workspace agent")
|
||||
return agt
|
||||
|
||||
@@ -9620,6 +9620,7 @@ func (q *FakeQuerier) InsertWorkspaceAgent(_ context.Context, arg database.Inser
|
||||
LifecycleState: database.WorkspaceAgentLifecycleStateCreated,
|
||||
DisplayApps: arg.DisplayApps,
|
||||
DisplayOrder: arg.DisplayOrder,
|
||||
APIKeyScope: arg.APIKeyScope,
|
||||
}
|
||||
|
||||
q.workspaceAgents = append(q.workspaceAgents, agent)
|
||||
|
||||
Generated
+8
@@ -5,6 +5,11 @@ CREATE TYPE agent_id_name_pair AS (
|
||||
name text
|
||||
);
|
||||
|
||||
CREATE TYPE agent_key_scope_enum AS ENUM (
|
||||
'all',
|
||||
'no_user_data'
|
||||
);
|
||||
|
||||
CREATE TYPE api_key_scope AS ENUM (
|
||||
'all',
|
||||
'application_connect'
|
||||
@@ -1837,6 +1842,7 @@ CREATE TABLE workspace_agents (
|
||||
api_version text DEFAULT ''::text NOT NULL,
|
||||
display_order integer DEFAULT 0 NOT NULL,
|
||||
parent_id uuid,
|
||||
api_key_scope agent_key_scope_enum DEFAULT 'all'::agent_key_scope_enum NOT NULL,
|
||||
CONSTRAINT max_logs_length CHECK ((logs_length <= 1048576)),
|
||||
CONSTRAINT subsystems_not_none CHECK ((NOT ('none'::workspace_agent_subsystem = ANY (subsystems))))
|
||||
);
|
||||
@@ -1863,6 +1869,8 @@ COMMENT ON COLUMN workspace_agents.ready_at IS 'The time the agent entered the r
|
||||
|
||||
COMMENT ON COLUMN workspace_agents.display_order IS 'Specifies the order in which to display agents in user interfaces.';
|
||||
|
||||
COMMENT ON COLUMN workspace_agents.api_key_scope IS 'Defines the scope of the API key associated with the agent. ''all'' allows access to everything, ''no_user_data'' restricts it to exclude user data.';
|
||||
|
||||
CREATE UNLOGGED TABLE workspace_app_audit_sessions (
|
||||
agent_id uuid NOT NULL,
|
||||
app_id uuid NOT NULL,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Remove the api_key_scope column from the workspace_agents table
|
||||
ALTER TABLE workspace_agents
|
||||
DROP COLUMN IF EXISTS api_key_scope;
|
||||
|
||||
-- Drop the enum type for API key scope
|
||||
DROP TYPE IF EXISTS agent_key_scope_enum;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Create the enum type for API key scope
|
||||
CREATE TYPE agent_key_scope_enum AS ENUM ('all', 'no_user_data');
|
||||
|
||||
-- Add the api_key_scope column to the workspace_agents table
|
||||
-- It defaults to 'all' to maintain existing behavior for current agents.
|
||||
ALTER TABLE workspace_agents
|
||||
ADD COLUMN api_key_scope agent_key_scope_enum NOT NULL DEFAULT 'all';
|
||||
|
||||
-- Add a comment explaining the purpose of the column
|
||||
COMMENT ON COLUMN workspace_agents.api_key_scope IS 'Defines the scope of the API key associated with the agent. ''all'' allows access to everything, ''no_user_data'' restricts it to exclude user data.';
|
||||
@@ -74,6 +74,64 @@ func AllAPIKeyScopeValues() []APIKeyScope {
|
||||
}
|
||||
}
|
||||
|
||||
type AgentKeyScopeEnum string
|
||||
|
||||
const (
|
||||
AgentKeyScopeEnumAll AgentKeyScopeEnum = "all"
|
||||
AgentKeyScopeEnumNoUserData AgentKeyScopeEnum = "no_user_data"
|
||||
)
|
||||
|
||||
func (e *AgentKeyScopeEnum) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = AgentKeyScopeEnum(s)
|
||||
case string:
|
||||
*e = AgentKeyScopeEnum(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for AgentKeyScopeEnum: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullAgentKeyScopeEnum struct {
|
||||
AgentKeyScopeEnum AgentKeyScopeEnum `json:"agent_key_scope_enum"`
|
||||
Valid bool `json:"valid"` // Valid is true if AgentKeyScopeEnum is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullAgentKeyScopeEnum) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.AgentKeyScopeEnum, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.AgentKeyScopeEnum.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullAgentKeyScopeEnum) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.AgentKeyScopeEnum), nil
|
||||
}
|
||||
|
||||
func (e AgentKeyScopeEnum) Valid() bool {
|
||||
switch e {
|
||||
case AgentKeyScopeEnumAll,
|
||||
AgentKeyScopeEnumNoUserData:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func AllAgentKeyScopeEnumValues() []AgentKeyScopeEnum {
|
||||
return []AgentKeyScopeEnum{
|
||||
AgentKeyScopeEnumAll,
|
||||
AgentKeyScopeEnumNoUserData,
|
||||
}
|
||||
}
|
||||
|
||||
type AppSharingLevel string
|
||||
|
||||
const (
|
||||
@@ -3406,6 +3464,8 @@ type WorkspaceAgent struct {
|
||||
// Specifies the order in which to display agents in user interfaces.
|
||||
DisplayOrder int32 `db:"display_order" json:"display_order"`
|
||||
ParentID uuid.NullUUID `db:"parent_id" json:"parent_id"`
|
||||
// Defines the scope of the API key associated with the agent. 'all' allows access to everything, 'no_user_data' restricts it to exclude user data.
|
||||
APIKeyScope AgentKeyScopeEnum `db:"api_key_scope" json:"api_key_scope"`
|
||||
}
|
||||
|
||||
// Workspace agent devcontainer configuration
|
||||
|
||||
@@ -13939,7 +13939,7 @@ func (q *sqlQuerier) DeleteOldWorkspaceAgentLogs(ctx context.Context, threshold
|
||||
const getWorkspaceAgentAndLatestBuildByAuthToken = `-- name: GetWorkspaceAgentAndLatestBuildByAuthToken :one
|
||||
SELECT
|
||||
workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at,
|
||||
workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.expanded_directory, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems, workspace_agents.display_apps, workspace_agents.api_version, workspace_agents.display_order, workspace_agents.parent_id,
|
||||
workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.expanded_directory, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems, workspace_agents.display_apps, workspace_agents.api_version, workspace_agents.display_order, workspace_agents.parent_id, workspace_agents.api_key_scope,
|
||||
workspace_build_with_user.id, workspace_build_with_user.created_at, workspace_build_with_user.updated_at, workspace_build_with_user.workspace_id, workspace_build_with_user.template_version_id, workspace_build_with_user.build_number, workspace_build_with_user.transition, workspace_build_with_user.initiator_id, workspace_build_with_user.provisioner_state, workspace_build_with_user.job_id, workspace_build_with_user.deadline, workspace_build_with_user.reason, workspace_build_with_user.daily_cost, workspace_build_with_user.max_deadline, workspace_build_with_user.template_version_preset_id, workspace_build_with_user.initiator_by_avatar_url, workspace_build_with_user.initiator_by_username
|
||||
FROM
|
||||
workspace_agents
|
||||
@@ -14030,6 +14030,7 @@ func (q *sqlQuerier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Cont
|
||||
&i.WorkspaceAgent.APIVersion,
|
||||
&i.WorkspaceAgent.DisplayOrder,
|
||||
&i.WorkspaceAgent.ParentID,
|
||||
&i.WorkspaceAgent.APIKeyScope,
|
||||
&i.WorkspaceBuild.ID,
|
||||
&i.WorkspaceBuild.CreatedAt,
|
||||
&i.WorkspaceBuild.UpdatedAt,
|
||||
@@ -14053,7 +14054,7 @@ func (q *sqlQuerier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Cont
|
||||
|
||||
const getWorkspaceAgentByID = `-- name: GetWorkspaceAgentByID :one
|
||||
SELECT
|
||||
id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, expanded_directory, logs_length, logs_overflowed, started_at, ready_at, subsystems, display_apps, api_version, display_order, parent_id
|
||||
id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, expanded_directory, logs_length, logs_overflowed, started_at, ready_at, subsystems, display_apps, api_version, display_order, parent_id, api_key_scope
|
||||
FROM
|
||||
workspace_agents
|
||||
WHERE
|
||||
@@ -14096,13 +14097,14 @@ func (q *sqlQuerier) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (W
|
||||
&i.APIVersion,
|
||||
&i.DisplayOrder,
|
||||
&i.ParentID,
|
||||
&i.APIKeyScope,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceAgentByInstanceID = `-- name: GetWorkspaceAgentByInstanceID :one
|
||||
SELECT
|
||||
id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, expanded_directory, logs_length, logs_overflowed, started_at, ready_at, subsystems, display_apps, api_version, display_order, parent_id
|
||||
id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, expanded_directory, logs_length, logs_overflowed, started_at, ready_at, subsystems, display_apps, api_version, display_order, parent_id, api_key_scope
|
||||
FROM
|
||||
workspace_agents
|
||||
WHERE
|
||||
@@ -14147,6 +14149,7 @@ func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInst
|
||||
&i.APIVersion,
|
||||
&i.DisplayOrder,
|
||||
&i.ParentID,
|
||||
&i.APIKeyScope,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -14366,7 +14369,7 @@ func (q *sqlQuerier) GetWorkspaceAgentScriptTimingsByBuildID(ctx context.Context
|
||||
|
||||
const getWorkspaceAgentsByResourceIDs = `-- name: GetWorkspaceAgentsByResourceIDs :many
|
||||
SELECT
|
||||
id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, expanded_directory, logs_length, logs_overflowed, started_at, ready_at, subsystems, display_apps, api_version, display_order, parent_id
|
||||
id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, expanded_directory, logs_length, logs_overflowed, started_at, ready_at, subsystems, display_apps, api_version, display_order, parent_id, api_key_scope
|
||||
FROM
|
||||
workspace_agents
|
||||
WHERE
|
||||
@@ -14415,6 +14418,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []
|
||||
&i.APIVersion,
|
||||
&i.DisplayOrder,
|
||||
&i.ParentID,
|
||||
&i.APIKeyScope,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -14431,7 +14435,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []
|
||||
|
||||
const getWorkspaceAgentsByWorkspaceAndBuildNumber = `-- name: GetWorkspaceAgentsByWorkspaceAndBuildNumber :many
|
||||
SELECT
|
||||
workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.expanded_directory, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems, workspace_agents.display_apps, workspace_agents.api_version, workspace_agents.display_order, workspace_agents.parent_id
|
||||
workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.expanded_directory, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems, workspace_agents.display_apps, workspace_agents.api_version, workspace_agents.display_order, workspace_agents.parent_id, workspace_agents.api_key_scope
|
||||
FROM
|
||||
workspace_agents
|
||||
JOIN
|
||||
@@ -14490,6 +14494,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsByWorkspaceAndBuildNumber(ctx context.Con
|
||||
&i.APIVersion,
|
||||
&i.DisplayOrder,
|
||||
&i.ParentID,
|
||||
&i.APIKeyScope,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -14505,7 +14510,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsByWorkspaceAndBuildNumber(ctx context.Con
|
||||
}
|
||||
|
||||
const getWorkspaceAgentsCreatedAfter = `-- name: GetWorkspaceAgentsCreatedAfter :many
|
||||
SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, expanded_directory, logs_length, logs_overflowed, started_at, ready_at, subsystems, display_apps, api_version, display_order, parent_id FROM workspace_agents WHERE created_at > $1
|
||||
SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, expanded_directory, logs_length, logs_overflowed, started_at, ready_at, subsystems, display_apps, api_version, display_order, parent_id, api_key_scope FROM workspace_agents WHERE created_at > $1
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceAgent, error) {
|
||||
@@ -14550,6 +14555,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, created
|
||||
&i.APIVersion,
|
||||
&i.DisplayOrder,
|
||||
&i.ParentID,
|
||||
&i.APIKeyScope,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -14566,7 +14572,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, created
|
||||
|
||||
const getWorkspaceAgentsInLatestBuildByWorkspaceID = `-- name: GetWorkspaceAgentsInLatestBuildByWorkspaceID :many
|
||||
SELECT
|
||||
workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.expanded_directory, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems, workspace_agents.display_apps, workspace_agents.api_version, workspace_agents.display_order, workspace_agents.parent_id
|
||||
workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.expanded_directory, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems, workspace_agents.display_apps, workspace_agents.api_version, workspace_agents.display_order, workspace_agents.parent_id, workspace_agents.api_key_scope
|
||||
FROM
|
||||
workspace_agents
|
||||
JOIN
|
||||
@@ -14627,6 +14633,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx context.Co
|
||||
&i.APIVersion,
|
||||
&i.DisplayOrder,
|
||||
&i.ParentID,
|
||||
&i.APIKeyScope,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -14662,10 +14669,11 @@ INSERT INTO
|
||||
troubleshooting_url,
|
||||
motd_file,
|
||||
display_apps,
|
||||
display_order
|
||||
display_order,
|
||||
api_key_scope
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, expanded_directory, logs_length, logs_overflowed, started_at, ready_at, subsystems, display_apps, api_version, display_order, parent_id
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) RETURNING id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, expanded_directory, logs_length, logs_overflowed, started_at, ready_at, subsystems, display_apps, api_version, display_order, parent_id, api_key_scope
|
||||
`
|
||||
|
||||
type InsertWorkspaceAgentParams struct {
|
||||
@@ -14688,6 +14696,7 @@ type InsertWorkspaceAgentParams struct {
|
||||
MOTDFile string `db:"motd_file" json:"motd_file"`
|
||||
DisplayApps []DisplayApp `db:"display_apps" json:"display_apps"`
|
||||
DisplayOrder int32 `db:"display_order" json:"display_order"`
|
||||
APIKeyScope AgentKeyScopeEnum `db:"api_key_scope" json:"api_key_scope"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspaceAgentParams) (WorkspaceAgent, error) {
|
||||
@@ -14711,6 +14720,7 @@ func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspa
|
||||
arg.MOTDFile,
|
||||
pq.Array(arg.DisplayApps),
|
||||
arg.DisplayOrder,
|
||||
arg.APIKeyScope,
|
||||
)
|
||||
var i WorkspaceAgent
|
||||
err := row.Scan(
|
||||
@@ -14746,6 +14756,7 @@ func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspa
|
||||
&i.APIVersion,
|
||||
&i.DisplayOrder,
|
||||
&i.ParentID,
|
||||
&i.APIKeyScope,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -48,10 +48,11 @@ INSERT INTO
|
||||
troubleshooting_url,
|
||||
motd_file,
|
||||
display_apps,
|
||||
display_order
|
||||
display_order,
|
||||
api_key_scope
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING *;
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) RETURNING *;
|
||||
|
||||
-- name: UpdateWorkspaceAgentConnectionByID :exec
|
||||
UPDATE
|
||||
|
||||
@@ -706,4 +706,82 @@ func TestExternalAuthCallback(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
t.Run("AgentAPIKeyScope", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tt := range []struct {
|
||||
apiKeyScope string
|
||||
expectsError bool
|
||||
}{
|
||||
{apiKeyScope: "all", expectsError: false},
|
||||
{apiKeyScope: "no_user_data", expectsError: true},
|
||||
} {
|
||||
t.Run(tt.apiKeyScope, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
ExternalAuthConfigs: []*externalauth.Config{{
|
||||
InstrumentedOAuth2Config: &testutil.OAuth2Config{},
|
||||
ID: "github",
|
||||
Regex: regexp.MustCompile(`github\.com`),
|
||||
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
|
||||
}},
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.PlanComplete,
|
||||
ProvisionApply: echo.ProvisionApplyWithAgentAndAPIKeyScope(authToken, tt.apiKeyScope),
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(authToken)
|
||||
|
||||
token, err := agentClient.ExternalAuth(t.Context(), agentsdk.ExternalAuthRequest{
|
||||
Match: "github.com/asd/asd",
|
||||
})
|
||||
|
||||
if tt.expectsError {
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, token.URL)
|
||||
|
||||
// Start waiting for the token callback...
|
||||
tokenChan := make(chan agentsdk.ExternalAuthResponse, 1)
|
||||
go func() {
|
||||
token, err := agentClient.ExternalAuth(t.Context(), agentsdk.ExternalAuthRequest{
|
||||
Match: "github.com/asd/asd",
|
||||
Listen: true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
tokenChan <- token
|
||||
}()
|
||||
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
resp := coderdtest.RequestExternalAuthCallback(t, "github", client)
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
|
||||
token = <-tokenChan
|
||||
require.Equal(t, "access_token", token.Username)
|
||||
|
||||
token, err = agentClient.ExternalAuth(t.Context(), agentsdk.ExternalAuthRequest{
|
||||
Match: "github.com/asd/asd",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,6 +145,10 @@ func (api *API) agentGitSSHKey(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
gitSSHKey, err := api.Database.GetGitSSHKey(ctx, workspace.OwnerID)
|
||||
if httpapi.IsUnauthorizedError(err) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching git SSH key.",
|
||||
|
||||
@@ -2,6 +2,7 @@ package coderd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/gitsshkey"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
@@ -126,3 +128,51 @@ func TestAgentGitSSHKey(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, agentKey.PrivateKey)
|
||||
}
|
||||
|
||||
func TestAgentGitSSHKey_APIKeyScopes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tt := range []struct {
|
||||
apiKeyScope string
|
||||
expectError bool
|
||||
}{
|
||||
{apiKeyScope: "all", expectError: false},
|
||||
{apiKeyScope: "no_user_data", expectError: true},
|
||||
} {
|
||||
t.Run(tt.apiKeyScope, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.PlanComplete,
|
||||
ProvisionApply: echo.ProvisionApplyWithAgentAndAPIKeyScope(authToken, tt.apiKeyScope),
|
||||
})
|
||||
project := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, project.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(authToken)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
_, err := agentClient.GitSSHKey(ctx)
|
||||
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,12 +109,18 @@ func ExtractWorkspaceAgentAndLatestBuild(opts ExtractWorkspaceAgentAndLatestBuil
|
||||
return
|
||||
}
|
||||
|
||||
subject, _, err := UserRBACSubject(ctx, opts.DB, row.WorkspaceTable.OwnerID, rbac.WorkspaceAgentScope(rbac.WorkspaceAgentScopeParams{
|
||||
WorkspaceID: row.WorkspaceTable.ID,
|
||||
OwnerID: row.WorkspaceTable.OwnerID,
|
||||
TemplateID: row.WorkspaceTable.TemplateID,
|
||||
VersionID: row.WorkspaceBuild.TemplateVersionID,
|
||||
}))
|
||||
subject, _, err := UserRBACSubject(
|
||||
ctx,
|
||||
opts.DB,
|
||||
row.WorkspaceTable.OwnerID,
|
||||
rbac.WorkspaceAgentScope(rbac.WorkspaceAgentScopeParams{
|
||||
WorkspaceID: row.WorkspaceTable.ID,
|
||||
OwnerID: row.WorkspaceTable.OwnerID,
|
||||
TemplateID: row.WorkspaceTable.TemplateID,
|
||||
VersionID: row.WorkspaceBuild.TemplateVersionID,
|
||||
BlockUserData: row.WorkspaceAgent.APIKeyScope == database.AgentKeyScopeEnumNoUserData,
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error with workspace agent authorization context.",
|
||||
|
||||
@@ -2140,6 +2140,11 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
|
||||
}
|
||||
}
|
||||
|
||||
apiKeyScope := database.AgentKeyScopeEnumAll
|
||||
if prAgent.ApiKeyScope == string(database.AgentKeyScopeEnumNoUserData) {
|
||||
apiKeyScope = database.AgentKeyScopeEnumNoUserData
|
||||
}
|
||||
|
||||
agentID := uuid.New()
|
||||
dbAgent, err := db.InsertWorkspaceAgent(ctx, database.InsertWorkspaceAgentParams{
|
||||
ID: agentID,
|
||||
@@ -2162,6 +2167,7 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
|
||||
ResourceMetadata: pqtype.NullRawMessage{},
|
||||
// #nosec G115 - Order represents a display order value that's always small and fits in int32
|
||||
DisplayOrder: int32(prAgent.Order),
|
||||
APIKeyScope: apiKeyScope,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert agent: %w", err)
|
||||
|
||||
@@ -1053,6 +1053,64 @@ func TestAuthorizeScope(t *testing.T) {
|
||||
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), actions: []policy.Action{policy.ActionCreate}, allow: false},
|
||||
},
|
||||
)
|
||||
|
||||
meID := uuid.New()
|
||||
user = Subject{
|
||||
ID: meID.String(),
|
||||
Roles: Roles{
|
||||
must(RoleByName(RoleMember())),
|
||||
must(RoleByName(ScopedRoleOrgMember(defOrg))),
|
||||
},
|
||||
Scope: must(ScopeNoUserData.Expand()),
|
||||
}
|
||||
|
||||
// Test 1: Verify that no_user_data scope prevents accessing user data
|
||||
testAuthorize(t, "ReadPersonalUser", user,
|
||||
cases(func(c authTestCase) authTestCase {
|
||||
c.actions = ResourceUser.AvailableActions()
|
||||
c.allow = false
|
||||
c.resource.ID = meID.String()
|
||||
return c
|
||||
}, []authTestCase{
|
||||
{resource: ResourceUser.WithOwner(meID.String()).InOrg(defOrg).WithID(meID)},
|
||||
}),
|
||||
)
|
||||
|
||||
// Test 2: Verify token can still perform regular member actions that don't involve user data
|
||||
testAuthorize(t, "NoUserData_CanStillUseRegularPermissions", user,
|
||||
// Test workspace access - should still work
|
||||
cases(func(c authTestCase) authTestCase {
|
||||
c.actions = []policy.Action{policy.ActionRead}
|
||||
c.allow = true
|
||||
return c
|
||||
}, []authTestCase{
|
||||
// Can still read owned workspaces
|
||||
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID)},
|
||||
}),
|
||||
// Test workspace create - should still work
|
||||
cases(func(c authTestCase) authTestCase {
|
||||
c.actions = []policy.Action{policy.ActionCreate}
|
||||
c.allow = true
|
||||
return c
|
||||
}, []authTestCase{
|
||||
// Can still create workspaces
|
||||
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID)},
|
||||
}),
|
||||
)
|
||||
|
||||
// Test 3: Verify token cannot perform actions outside of member role
|
||||
testAuthorize(t, "NoUserData_CannotExceedMemberRole", user,
|
||||
cases(func(c authTestCase) authTestCase {
|
||||
c.actions = []policy.Action{policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}
|
||||
c.allow = false
|
||||
return c
|
||||
}, []authTestCase{
|
||||
// Cannot access other users' workspaces
|
||||
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("other-user")},
|
||||
// Cannot access admin resources
|
||||
{resource: ResourceOrganization.WithID(defOrg)},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// cases applies a given function to all test cases. This makes generalities easier to create.
|
||||
|
||||
+30
-8
@@ -11,10 +11,11 @@ import (
|
||||
)
|
||||
|
||||
type WorkspaceAgentScopeParams struct {
|
||||
WorkspaceID uuid.UUID
|
||||
OwnerID uuid.UUID
|
||||
TemplateID uuid.UUID
|
||||
VersionID uuid.UUID
|
||||
WorkspaceID uuid.UUID
|
||||
OwnerID uuid.UUID
|
||||
TemplateID uuid.UUID
|
||||
VersionID uuid.UUID
|
||||
BlockUserData bool
|
||||
}
|
||||
|
||||
// WorkspaceAgentScope returns a scope that is the same as ScopeAll but can only
|
||||
@@ -25,16 +26,25 @@ func WorkspaceAgentScope(params WorkspaceAgentScopeParams) Scope {
|
||||
panic("all uuids must be non-nil, this is a developer error")
|
||||
}
|
||||
|
||||
allScope, err := ScopeAll.Expand()
|
||||
if err != nil {
|
||||
panic("failed to expand scope all, this should never happen")
|
||||
var (
|
||||
scope Scope
|
||||
err error
|
||||
)
|
||||
if params.BlockUserData {
|
||||
scope, err = ScopeNoUserData.Expand()
|
||||
} else {
|
||||
scope, err = ScopeAll.Expand()
|
||||
}
|
||||
if err != nil {
|
||||
panic("failed to expand scope, this should never happen")
|
||||
}
|
||||
|
||||
return Scope{
|
||||
// TODO: We want to limit the role too to be extra safe.
|
||||
// Even though the allowlist blocks anything else, it is still good
|
||||
// incase we change the behavior of the allowlist. The allowlist is new
|
||||
// and evolving.
|
||||
Role: allScope.Role,
|
||||
Role: scope.Role,
|
||||
// This prevents the agent from being able to access any other resource.
|
||||
// Include the list of IDs of anything that is required for the
|
||||
// agent to function.
|
||||
@@ -50,6 +60,7 @@ func WorkspaceAgentScope(params WorkspaceAgentScopeParams) Scope {
|
||||
const (
|
||||
ScopeAll ScopeName = "all"
|
||||
ScopeApplicationConnect ScopeName = "application_connect"
|
||||
ScopeNoUserData ScopeName = "no_user_data"
|
||||
)
|
||||
|
||||
// TODO: Support passing in scopeID list for allowlisting resources.
|
||||
@@ -81,6 +92,17 @@ var builtinScopes = map[ScopeName]Scope{
|
||||
},
|
||||
AllowIDList: []string{policy.WildcardSymbol},
|
||||
},
|
||||
|
||||
ScopeNoUserData: {
|
||||
Role: Role{
|
||||
Identifier: RoleIdentifier{Name: fmt.Sprintf("Scope_%s", ScopeNoUserData)},
|
||||
DisplayName: "Scope without access to user data",
|
||||
Site: allPermsExcept(ResourceUser),
|
||||
Org: map[string][]Permission{},
|
||||
User: []Permission{},
|
||||
},
|
||||
AllowIDList: []string{policy.WildcardSymbol},
|
||||
},
|
||||
}
|
||||
|
||||
type ExpandableScope interface {
|
||||
|
||||
@@ -42,7 +42,7 @@ func TimezoneIANA() (*time.Location, error) {
|
||||
return nil, xerrors.Errorf("read location of %s: %w", zoneInfoPath, err)
|
||||
}
|
||||
|
||||
stripped := strings.Replace(lp, realZoneInfoPath, "", -1)
|
||||
stripped := strings.ReplaceAll(lp, realZoneInfoPath, "")
|
||||
stripped = strings.TrimPrefix(stripped, string(filepath.Separator))
|
||||
loc, err = time.LoadLocation(stripped)
|
||||
if err != nil {
|
||||
|
||||
@@ -1635,6 +1635,15 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
// Pre-check if the caller can read the external auth links for the owner of the
|
||||
// workspace. Do this up front because a sql.ErrNoRows is expected if the user is
|
||||
// in the flow of authenticating. If no row is present, the auth check is delayed
|
||||
// until the user authenticates. It is preferred to reject early.
|
||||
if !api.Authorize(r, policy.ActionReadPersonal, rbac.ResourceUserObject(workspace.OwnerID)) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
|
||||
var previousToken *database.ExternalAuthLink
|
||||
// handleRetrying will attempt to continually check for a new token
|
||||
// if listen is true. This is useful if an error is encountered in the
|
||||
|
||||
Reference in New Issue
Block a user