feat: add API key scope to restrict access to user data (#17692)

This commit is contained in:
Thomas Kosiewski
2025-05-15 15:32:52 +01:00
committed by GitHub
parent ee2aeb44d7
commit 1bacd82e80
28 changed files with 824 additions and 447 deletions
+6 -4
View File
@@ -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)
+3 -2
View File
@@ -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) {
+1
View File
@@ -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
+1
View File
@@ -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)
+8
View File
@@ -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.';
+60
View File
@@ -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
+20 -9
View File
@@ -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
}
+3 -2
View File
@@ -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
+78
View File
@@ -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)
})
}
})
}
+4
View File
@@ -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.",
+50
View File
@@ -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)
}
})
}
}
+12 -6
View File
@@ -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)
+58
View File
@@ -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
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+9
View File
@@ -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