feat: Add agent authentication based on instance ID (#336)

* feat: Add agent authentication based on instance ID

Each cloud has it's own unique instance identity signatures, which
can be used for zero-token authentication. This change adds support
for tracking by "instance_id", and automatically authenticating
with Google Cloud.

* Add test for CLI

* Fix workspace agent request name

* Fix race with adding to wait group

* Fix name of instance identity token
This commit is contained in:
Kyle Carberry
2022-02-21 20:36:29 +00:00
committed by GitHub
parent 67613da86d
commit 8958b641e9
41 changed files with 752 additions and 251 deletions
+9
View File
@@ -5,6 +5,7 @@ import (
"sync"
"github.com/go-chi/chi/v5"
"google.golang.org/api/idtoken"
"cdr.dev/slog"
"github.com/coder/coder/database"
@@ -18,6 +19,8 @@ type Options struct {
Logger slog.Logger
Database database.Store
Pubsub database.Pubsub
GoogleTokenValidator *idtoken.Validator
}
// New constructs the Coder API into an HTTP handler.
@@ -107,6 +110,12 @@ func New(options *Options) (http.Handler, func()) {
})
})
r.Route("/workspaceagent", func(r chi.Router) {
r.Route("/authenticate", func(r chi.Router) {
r.Post("/google-instance-identity", api.postAuthenticateWorkspaceAgentUsingGoogleInstanceIdentity)
})
})
r.Route("/files", func(r chi.Router) {
r.Use(httpmw.ExtractAPIKey(options.Database, nil))
r.Post("/", api.postFiles)
+28 -1
View File
@@ -15,6 +15,9 @@ import (
"github.com/google/uuid"
"github.com/moby/moby/pkg/namesgenerator"
"github.com/stretchr/testify/require"
"go.opencensus.io/stats/view"
"google.golang.org/api/idtoken"
"google.golang.org/api/option"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
@@ -29,9 +32,31 @@ import (
"github.com/coder/coder/provisionersdk/proto"
)
type Options struct {
GoogleTokenValidator *idtoken.Validator
}
// New constructs an in-memory coderd instance and returns
// the connected client.
func New(t *testing.T) *codersdk.Client {
func New(t *testing.T, options *Options) *codersdk.Client {
// Stops the opencensus.io worker from leaking a goroutine.
// The worker isn't used anyways, and is an indirect dependency
// of the Google Cloud SDK.
t.Cleanup(func() {
view.Stop()
})
if options == nil {
options = &Options{}
}
if options.GoogleTokenValidator == nil {
ctx, cancelFunc := context.WithCancel(context.Background())
t.Cleanup(cancelFunc)
var err error
options.GoogleTokenValidator, err = idtoken.NewValidator(ctx, option.WithoutAuthentication())
require.NoError(t, err)
}
// This can be hotswapped for a live database instance.
db := databasefake.New()
pubsub := database.NewPubsubInMemory()
@@ -59,6 +84,8 @@ func New(t *testing.T) *codersdk.Client {
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
Database: db,
Pubsub: pubsub,
GoogleTokenValidator: options.GoogleTokenValidator,
})
srv := httptest.NewUnstartedServer(handler)
srv.Config.BaseContext = func(_ net.Listener) context.Context {
+1 -1
View File
@@ -19,7 +19,7 @@ func TestMain(m *testing.M) {
func TestNew(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
closer := coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
+3 -3
View File
@@ -14,7 +14,7 @@ func TestPostFiles(t *testing.T) {
t.Parallel()
t.Run("BadContentType", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateInitialUser(t, client)
_, err := client.UploadFile(context.Background(), "bad", []byte{'a'})
require.Error(t, err)
@@ -22,7 +22,7 @@ func TestPostFiles(t *testing.T) {
t.Run("Insert", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateInitialUser(t, client)
_, err := client.UploadFile(context.Background(), codersdk.ContentTypeTar, make([]byte, 1024))
require.NoError(t, err)
@@ -30,7 +30,7 @@ func TestPostFiles(t *testing.T) {
t.Run("InsertAlreadyExists", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateInitialUser(t, client)
data := make([]byte, 1024)
_, err := client.UploadFile(context.Background(), codersdk.ContentTypeTar, data)
+8 -8
View File
@@ -19,7 +19,7 @@ func TestPostProjectImportByOrganization(t *testing.T) {
t.Parallel()
t.Run("FileNotFound", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
_, err := client.CreateProjectImportJob(context.Background(), user.Organization, coderd.CreateProjectImportJobRequest{
StorageMethod: database.ProvisionerStorageMethodFile,
@@ -30,7 +30,7 @@ func TestPostProjectImportByOrganization(t *testing.T) {
})
t.Run("Create", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
_ = coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
})
@@ -40,7 +40,7 @@ func TestProjectImportJobSchemasByID(t *testing.T) {
t.Parallel()
t.Run("ListRunning", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
_, err := client.ProjectImportJobSchemas(context.Background(), user.Organization, job.ID)
@@ -50,7 +50,7 @@ func TestProjectImportJobSchemasByID(t *testing.T) {
})
t.Run("List", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, &echo.Responses{
@@ -80,7 +80,7 @@ func TestProjectImportJobParametersByID(t *testing.T) {
t.Parallel()
t.Run("ListRunning", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
_, err := client.ProjectImportJobSchemas(context.Background(), user.Organization, job.ID)
@@ -90,7 +90,7 @@ func TestProjectImportJobParametersByID(t *testing.T) {
})
t.Run("List", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, &echo.Responses{
@@ -126,7 +126,7 @@ func TestProjectImportJobResourcesByID(t *testing.T) {
t.Parallel()
t.Run("ListRunning", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
_, err := client.ProjectImportJobResources(context.Background(), user.Organization, job.ID)
@@ -136,7 +136,7 @@ func TestProjectImportJobResourcesByID(t *testing.T) {
})
t.Run("List", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, &echo.Responses{
+11 -11
View File
@@ -18,7 +18,7 @@ func TestProjects(t *testing.T) {
t.Run("ListEmpty", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateInitialUser(t, client)
projects, err := client.Projects(context.Background(), "")
require.NoError(t, err)
@@ -28,7 +28,7 @@ func TestProjects(t *testing.T) {
t.Run("List", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
_ = coderdtest.CreateProject(t, client, user.Organization, job.ID)
@@ -39,7 +39,7 @@ func TestProjects(t *testing.T) {
t.Run("ListWorkspaceOwnerCount", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
@@ -58,7 +58,7 @@ func TestProjectsByOrganization(t *testing.T) {
t.Parallel()
t.Run("ListEmpty", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
projects, err := client.Projects(context.Background(), user.Organization)
require.NoError(t, err)
@@ -68,7 +68,7 @@ func TestProjectsByOrganization(t *testing.T) {
t.Run("List", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
_ = coderdtest.CreateProject(t, client, user.Organization, job.ID)
@@ -82,7 +82,7 @@ func TestPostProjectsByOrganization(t *testing.T) {
t.Parallel()
t.Run("Create", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
_ = coderdtest.CreateProject(t, client, user.Organization, job.ID)
@@ -90,7 +90,7 @@ func TestPostProjectsByOrganization(t *testing.T) {
t.Run("AlreadyExists", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
@@ -108,7 +108,7 @@ func TestProjectByOrganization(t *testing.T) {
t.Parallel()
t.Run("Get", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
@@ -121,7 +121,7 @@ func TestPostParametersByProject(t *testing.T) {
t.Parallel()
t.Run("Create", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
@@ -139,7 +139,7 @@ func TestParametersByProject(t *testing.T) {
t.Parallel()
t.Run("ListEmpty", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
@@ -150,7 +150,7 @@ func TestParametersByProject(t *testing.T) {
t.Run("List", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
+3 -3
View File
@@ -14,7 +14,7 @@ func TestProjectVersionsByOrganization(t *testing.T) {
t.Parallel()
t.Run("List", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
@@ -29,7 +29,7 @@ func TestProjectVersionByOrganizationAndName(t *testing.T) {
t.Parallel()
t.Run("Get", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
@@ -42,7 +42,7 @@ func TestPostProjectVersionByOrganization(t *testing.T) {
t.Parallel()
t.Run("Create", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
+9 -4
View File
@@ -52,6 +52,9 @@ func (api *api) provisionerDaemons(rw http.ResponseWriter, r *http.Request) {
// Serves the provisioner daemon protobuf API over a WebSocket.
func (api *api) provisionerDaemonsServe(rw http.ResponseWriter, r *http.Request) {
api.websocketWaitGroup.Add(1)
defer api.websocketWaitGroup.Done()
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
// Need to disable compression to avoid a data-race.
CompressionMode: websocket.CompressionDisabled,
@@ -62,8 +65,6 @@ func (api *api) provisionerDaemonsServe(rw http.ResponseWriter, r *http.Request)
})
return
}
api.websocketWaitGroup.Add(1)
defer api.websocketWaitGroup.Done()
daemon, err := api.Database.InsertProvisionerDaemon(r.Context(), database.InsertProvisionerDaemonParams{
ID: uuid.New(),
@@ -519,8 +520,12 @@ func (server *provisionerdServer) CompleteJob(ctx context.Context, completed *pr
ID: uuid.New(),
CreatedAt: database.Now(),
WorkspaceHistoryID: input.WorkspaceHistoryID,
Type: protoResource.Type,
Name: protoResource.Name,
InstanceID: sql.NullString{
Valid: protoResource.InstanceId != "",
String: protoResource.InstanceId,
},
Type: protoResource.Type,
Name: protoResource.Name,
// TODO: Generate this at the variable validation phase.
// Set the value in `default_source`, and disallow overwrite.
WorkspaceAgentToken: uuid.NewString(),
+1 -1
View File
@@ -15,7 +15,7 @@ func TestProvisionerDaemons(t *testing.T) {
// in their respective files.
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
_ = coderdtest.NewProvisionerDaemon(t, client)
require.Eventually(t, func() bool {
daemons, err := client.ProvisionerDaemons(context.Background())
+10 -10
View File
@@ -21,7 +21,7 @@ func TestPostProvisionerImportJobByOrganization(t *testing.T) {
t.Parallel()
t.Run("Create", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
_ = coderdtest.NewProvisionerDaemon(t, client)
before := time.Now()
@@ -57,7 +57,7 @@ func TestPostProvisionerImportJobByOrganization(t *testing.T) {
t.Run("CreateWithParameters", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
_ = coderdtest.NewProvisionerDaemon(t, client)
data, err := echo.Tar(&echo.Responses{
@@ -99,7 +99,7 @@ func TestProvisionerJobParametersByID(t *testing.T) {
t.Parallel()
t.Run("NotImported", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
_, err := client.ProjectImportJobParameters(context.Background(), user.Organization, job.ID)
@@ -110,7 +110,7 @@ func TestProvisionerJobParametersByID(t *testing.T) {
t.Run("List", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
_ = coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, &echo.Responses{
@@ -140,7 +140,7 @@ func TestProvisionerJobParametersByID(t *testing.T) {
t.Run("ListNoRedisplay", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
_ = coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, &echo.Responses{
@@ -174,9 +174,9 @@ func TestProvisionerJobParametersByID(t *testing.T) {
func TestProvisionerJobResourcesByID(t *testing.T) {
t.Parallel()
t.Run("Something", func(t *testing.T) {
t.Run("List", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
_ = coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, &echo.Responses{
@@ -212,7 +212,7 @@ func TestProvisionerJobLogsByName(t *testing.T) {
t.Parallel()
t.Run("List", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, &echo.Responses{
@@ -248,7 +248,7 @@ func TestProvisionerJobLogsByName(t *testing.T) {
t.Run("StreamAfterComplete", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, &echo.Responses{
@@ -289,7 +289,7 @@ func TestProvisionerJobLogsByName(t *testing.T) {
t.Run("StreamWhileRunning", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, &echo.Responses{
+16 -16
View File
@@ -17,7 +17,7 @@ func TestUser(t *testing.T) {
t.Parallel()
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
has, err := client.HasInitialUser(context.Background())
require.NoError(t, err)
require.False(t, has)
@@ -25,7 +25,7 @@ func TestUser(t *testing.T) {
t.Run("Found", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateInitialUser(t, client)
has, err := client.HasInitialUser(context.Background())
require.NoError(t, err)
@@ -37,14 +37,14 @@ func TestPostUser(t *testing.T) {
t.Parallel()
t.Run("BadRequest", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
_, err := client.CreateInitialUser(context.Background(), coderd.CreateInitialUserRequest{})
require.Error(t, err)
})
t.Run("AlreadyExists", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateInitialUser(t, client)
_, err := client.CreateInitialUser(context.Background(), coderd.CreateInitialUserRequest{
Email: "some@email.com",
@@ -59,7 +59,7 @@ func TestPostUser(t *testing.T) {
t.Run("Create", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateInitialUser(t, client)
})
}
@@ -68,14 +68,14 @@ func TestPostUsers(t *testing.T) {
t.Parallel()
t.Run("BadRequest", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
_, err := client.CreateInitialUser(context.Background(), coderd.CreateInitialUserRequest{})
require.Error(t, err)
})
t.Run("Conflicting", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
_, err := client.CreateInitialUser(context.Background(), coderd.CreateInitialUserRequest{
Email: user.Email,
@@ -90,7 +90,7 @@ func TestPostUsers(t *testing.T) {
t.Run("Create", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateInitialUser(t, client)
_, err := client.CreateUser(context.Background(), coderd.CreateUserRequest{
Email: "another@user.org",
@@ -103,7 +103,7 @@ func TestPostUsers(t *testing.T) {
func TestUserByName(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateInitialUser(t, client)
_, err := client.User(context.Background(), "")
require.NoError(t, err)
@@ -111,7 +111,7 @@ func TestUserByName(t *testing.T) {
func TestOrganizationsByUser(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateInitialUser(t, client)
orgs, err := client.UserOrganizations(context.Background(), "")
require.NoError(t, err)
@@ -123,7 +123,7 @@ func TestPostKey(t *testing.T) {
t.Parallel()
t.Run("InvalidUser", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateInitialUser(t, client)
// Clear session token
@@ -137,7 +137,7 @@ func TestPostKey(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateInitialUser(t, client)
apiKey, err := client.CreateAPIKey(context.Background())
require.NotNil(t, apiKey)
@@ -150,7 +150,7 @@ func TestPostLogin(t *testing.T) {
t.Parallel()
t.Run("InvalidUser", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
_, err := client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
Email: "my@email.org",
Password: "password",
@@ -162,7 +162,7 @@ func TestPostLogin(t *testing.T) {
t.Run("BadPassword", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
_, err := client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
Email: user.Email,
@@ -175,7 +175,7 @@ func TestPostLogin(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
_, err := client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
Email: user.Email,
@@ -191,7 +191,7 @@ func TestPostLogout(t *testing.T) {
t.Run("ClearCookie", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
fullURL, err := client.URL.Parse("/api/v2/logout")
require.NoError(t, err, "Server URL should parse successfully")
+91
View File
@@ -0,0 +1,91 @@
package coderd
import (
"database/sql"
"errors"
"fmt"
"net/http"
"github.com/go-chi/render"
"github.com/coder/coder/httpapi"
"github.com/mitchellh/mapstructure"
)
type GoogleInstanceIdentityToken struct {
JSONWebToken string `json:"json_web_token" validate:"required"`
}
// WorkspaceAgentAuthenticateResponse is returned when an instance ID
// has been exchanged for a session token.
type WorkspaceAgentAuthenticateResponse struct {
SessionToken string `json:"session_token"`
}
// Google Compute Engine supports instance identity verification:
// https://cloud.google.com/compute/docs/instances/verifying-instance-identity
// Using this, we can exchange a signed instance payload for an agent token.
func (api *api) postAuthenticateWorkspaceAgentUsingGoogleInstanceIdentity(rw http.ResponseWriter, r *http.Request) {
var req GoogleInstanceIdentityToken
if !httpapi.Read(rw, r, &req) {
return
}
// We leave the audience blank. It's not important we validate who made the token.
payload, err := api.GoogleTokenValidator.Validate(r.Context(), req.JSONWebToken, "")
if err != nil {
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
Message: fmt.Sprintf("validate: %s", err),
})
return
}
claims := struct {
Google struct {
ComputeEngine struct {
InstanceID string `mapstructure:"instance_id"`
} `mapstructure:"compute_engine"`
} `mapstructure:"google"`
}{}
err = mapstructure.Decode(payload.Claims, &claims)
if err != nil {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: fmt.Sprintf("decode jwt claims: %s", err),
})
return
}
resource, err := api.Database.GetWorkspaceResourceByInstanceID(r.Context(), claims.Google.ComputeEngine.InstanceID)
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(rw, http.StatusNotFound, httpapi.Response{
Message: fmt.Sprintf("instance with id %q not found", claims.Google.ComputeEngine.InstanceID),
})
return
}
resourceHistory, err := api.Database.GetWorkspaceHistoryByID(r.Context(), resource.WorkspaceHistoryID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get workspace history: %s", err),
})
return
}
// This token should only be exchanged if the instance ID is valid
// for the latest history. If an instance ID is recycled by a cloud,
// we'd hate to leak access to a user's workspace.
latestHistory, err := api.Database.GetWorkspaceHistoryByWorkspaceIDWithoutAfter(r.Context(), resourceHistory.WorkspaceID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get latest workspace history: %s", err),
})
return
}
if latestHistory.ID.String() != resourceHistory.ID.String() {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: fmt.Sprintf("resource found for id %q, but isn't registered on the latest history", claims.Google.ComputeEngine.InstanceID),
})
return
}
render.Status(r, http.StatusOK)
render.JSON(rw, r, WorkspaceAgentAuthenticateResponse{
SessionToken: resource.WorkspaceAgentToken,
})
}
+176
View File
@@ -0,0 +1,176 @@
package coderd_test
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"io/ioutil"
"math/big"
"net/http"
"testing"
"time"
"cloud.google.com/go/compute/metadata"
"github.com/golang-jwt/jwt"
"github.com/stretchr/testify/require"
"google.golang.org/api/idtoken"
"google.golang.org/api/option"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
"github.com/coder/coder/database"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
)
func TestPostWorkspaceAgentAuthenticateGoogleInstanceIdentity(t *testing.T) {
t.Parallel()
t.Run("Expired", func(t *testing.T) {
t.Parallel()
instanceID := "instanceidentifier"
signedKey, keyID, privateKey := createSignedToken(t, instanceID, &jwt.MapClaims{})
validator := createValidator(t, keyID, privateKey)
client := coderdtest.New(t, &coderdtest.Options{
GoogleTokenValidator: validator,
})
_, err := client.AuthenticateWorkspaceAgentUsingGoogleCloudIdentity(context.Background(), "", createMetadataClient(signedKey))
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode())
})
t.Run("InstanceNotFound", func(t *testing.T) {
t.Parallel()
instanceID := "instanceidentifier"
signedKey, keyID, privateKey := createSignedToken(t, instanceID, nil)
validator := createValidator(t, keyID, privateKey)
client := coderdtest.New(t, &coderdtest.Options{
GoogleTokenValidator: validator,
})
_, err := client.AuthenticateWorkspaceAgentUsingGoogleCloudIdentity(context.Background(), "", createMetadataClient(signedKey))
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
t.Run("Success", func(t *testing.T) {
t.Parallel()
instanceID := "instanceidentifier"
signedKey, keyID, privateKey := createSignedToken(t, instanceID, nil)
validator := createValidator(t, keyID, privateKey)
client := coderdtest.New(t, &coderdtest.Options{
GoogleTokenValidator: validator,
})
user := coderdtest.CreateInitialUser(t, client)
coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, &echo.Responses{
Parse: echo.ParseComplete,
Provision: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "somename",
Type: "someinstance",
InstanceId: instanceID,
}},
},
},
}},
})
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
coderdtest.AwaitProjectImportJob(t, client, user.Organization, job.ID)
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
firstHistory, err := client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceProvisionJob(t, client, user.Organization, firstHistory.ProvisionJobID)
_, err = client.AuthenticateWorkspaceAgentUsingGoogleCloudIdentity(context.Background(), "", createMetadataClient(signedKey))
require.NoError(t, err)
})
}
// Used to easily create an HTTP transport!
type roundTripper func(req *http.Request) (*http.Response, error)
func (r roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return r(req)
}
// Create's a new Google metadata client to authenticate.
func createMetadataClient(signedKey string) *metadata.Client {
return metadata.NewClient(&http.Client{
Transport: roundTripper(func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: ioutil.NopCloser(bytes.NewReader([]byte(signedKey))),
Header: make(http.Header),
}, nil
}),
})
}
// Create's a signed JWT with a randomly generated private key.
func createSignedToken(t *testing.T, instanceID string, claims *jwt.MapClaims) (signedKey string, keyID string, privateKey *rsa.PrivateKey) {
keyID, err := cryptorand.String(12)
require.NoError(t, err)
if claims == nil {
claims = &jwt.MapClaims{
"exp": time.Now().AddDate(1, 0, 0).Unix(),
"google": map[string]interface{}{
"compute_engine": map[string]string{
"instance_id": instanceID,
},
},
}
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = keyID
privateKey, err = rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
signedKey, err = token.SignedString(privateKey)
require.NoError(t, err)
return signedKey, keyID, privateKey
}
// Create's a validator that verifies against the provided private key.
// In a production scenario, the validator calls against the Google OAuth API
// to obtain certificates.
func createValidator(t *testing.T, keyID string, privateKey *rsa.PrivateKey) *idtoken.Validator {
// Taken from: https://github.com/googleapis/google-api-go-client/blob/4bb729045d611fa77bdbeb971f6a1204ba23161d/idtoken/validate.go#L57-L75
type jwk struct {
Kid string `json:"kid"`
N string `json:"n"`
E string `json:"e"`
}
type certResponse struct {
Keys []jwk `json:"keys"`
}
validator, err := idtoken.NewValidator(context.Background(), option.WithHTTPClient(&http.Client{
Transport: roundTripper(func(r *http.Request) (*http.Response, error) {
data, err := json.Marshal(certResponse{
Keys: []jwk{{
Kid: keyID,
N: base64.RawURLEncoding.EncodeToString(privateKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(new(big.Int).SetInt64(int64(privateKey.E)).Bytes()),
}},
})
require.NoError(t, err)
return &http.Response{
StatusCode: http.StatusOK,
Body: ioutil.NopCloser(bytes.NewReader(data)),
Header: make(http.Header),
}, nil
}),
}))
require.NoError(t, err)
return validator
}
+7 -7
View File
@@ -20,7 +20,7 @@ func TestPostWorkspaceHistoryByUser(t *testing.T) {
t.Parallel()
t.Run("NoProjectVersion", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
@@ -37,7 +37,7 @@ func TestPostWorkspaceHistoryByUser(t *testing.T) {
t.Run("ProjectVersionFailedImport", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, &echo.Responses{
@@ -58,7 +58,7 @@ func TestPostWorkspaceHistoryByUser(t *testing.T) {
t.Run("AlreadyActive", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
closeDaemon := coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
@@ -84,7 +84,7 @@ func TestPostWorkspaceHistoryByUser(t *testing.T) {
t.Run("UpdatePriorAfterField", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
@@ -114,7 +114,7 @@ func TestWorkspaceHistoryByUser(t *testing.T) {
t.Parallel()
t.Run("ListEmpty", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
@@ -128,7 +128,7 @@ func TestWorkspaceHistoryByUser(t *testing.T) {
t.Run("List", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
@@ -149,7 +149,7 @@ func TestWorkspaceHistoryByUser(t *testing.T) {
func TestWorkspaceHistoryByName(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
+9 -9
View File
@@ -17,7 +17,7 @@ func TestWorkspaces(t *testing.T) {
t.Parallel()
t.Run("ListNone", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateInitialUser(t, client)
workspaces, err := client.Workspaces(context.Background(), "")
require.NoError(t, err)
@@ -27,7 +27,7 @@ func TestWorkspaces(t *testing.T) {
t.Run("List", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
@@ -42,7 +42,7 @@ func TestPostWorkspaceByUser(t *testing.T) {
t.Parallel()
t.Run("InvalidProject", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateInitialUser(t, client)
_, err := client.CreateWorkspace(context.Background(), "", coderd.CreateWorkspaceRequest{
ProjectID: uuid.New(),
@@ -56,7 +56,7 @@ func TestPostWorkspaceByUser(t *testing.T) {
t.Run("NoProjectAccess", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
@@ -88,7 +88,7 @@ func TestPostWorkspaceByUser(t *testing.T) {
t.Run("AlreadyExists", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
@@ -105,7 +105,7 @@ func TestPostWorkspaceByUser(t *testing.T) {
t.Run("Create", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
@@ -115,7 +115,7 @@ func TestPostWorkspaceByUser(t *testing.T) {
func TestWorkspaceByUser(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
@@ -128,7 +128,7 @@ func TestWorkspacesByProject(t *testing.T) {
t.Parallel()
t.Run("ListEmpty", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
@@ -139,7 +139,7 @@ func TestWorkspacesByProject(t *testing.T) {
t.Run("List", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
client := coderdtest.New(t, nil)
user := coderdtest.CreateInitialUser(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)