mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add owner_oidc_access_token to coder_workspace data source (#6042)
See the discussion in Discord here: https://discord.com/channels/747933592273027093/1071182088490987542/1071182088490987542 Related provider PR: coder/terraform-provider-coder#91
This commit is contained in:
@@ -835,6 +835,7 @@ func (api *API) CreateInMemoryProvisionerDaemon(ctx context.Context, debounce ti
|
||||
err = proto.DRPCRegisterProvisionerDaemon(mux, &provisionerdserver.Server{
|
||||
AccessURL: api.AccessURL,
|
||||
ID: daemon.ID,
|
||||
OIDCConfig: api.OIDCConfig,
|
||||
Database: api.Database,
|
||||
Pubsub: api.Pubsub,
|
||||
Provisioners: daemon.Provisioners,
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/tabbed/pqtype"
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/xerrors"
|
||||
protobuf "google.golang.org/protobuf/proto"
|
||||
|
||||
@@ -27,6 +28,7 @@ import (
|
||||
"github.com/coder/coder/coderd/audit"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/parameter"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/coderd/telemetry"
|
||||
@@ -58,6 +60,7 @@ type Server struct {
|
||||
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
|
||||
|
||||
AcquireJobDebounce time.Duration
|
||||
OIDCConfig httpmw.OAuth2Config
|
||||
}
|
||||
|
||||
// AcquireJob queries the database to lock a job.
|
||||
@@ -168,6 +171,14 @@ func (server *Server) AcquireJob(ctx context.Context, _ *proto.Empty) (*proto.Ac
|
||||
return nil, failJob(fmt.Sprintf("publish workspace update: %s", err))
|
||||
}
|
||||
|
||||
var workspaceOwnerOIDCAccessToken string
|
||||
if server.OIDCConfig != nil {
|
||||
workspaceOwnerOIDCAccessToken, err = obtainOIDCAccessToken(ctx, server.Database, server.OIDCConfig, owner.ID)
|
||||
if err != nil {
|
||||
return nil, failJob(fmt.Sprintf("obtain OIDC access token: %s", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Compute parameters for the workspace to consume.
|
||||
parameters, err := parameter.Compute(ctx, server.Database, parameter.ComputeScope{
|
||||
TemplateImportJobID: templateVersion.JobID,
|
||||
@@ -208,15 +219,16 @@ func (server *Server) AcquireJob(ctx context.Context, _ *proto.Empty) (*proto.Ac
|
||||
RichParameterValues: convertRichParameterValues(workspaceBuildParameters),
|
||||
VariableValues: asVariableValues(templateVariables),
|
||||
Metadata: &sdkproto.Provision_Metadata{
|
||||
CoderUrl: server.AccessURL.String(),
|
||||
WorkspaceTransition: transition,
|
||||
WorkspaceName: workspace.Name,
|
||||
WorkspaceOwner: owner.Username,
|
||||
WorkspaceOwnerEmail: owner.Email,
|
||||
WorkspaceId: workspace.ID.String(),
|
||||
WorkspaceOwnerId: owner.ID.String(),
|
||||
TemplateName: template.Name,
|
||||
TemplateVersion: templateVersion.Name,
|
||||
CoderUrl: server.AccessURL.String(),
|
||||
WorkspaceTransition: transition,
|
||||
WorkspaceName: workspace.Name,
|
||||
WorkspaceOwner: owner.Username,
|
||||
WorkspaceOwnerEmail: owner.Email,
|
||||
WorkspaceOwnerOidcAccessToken: workspaceOwnerOIDCAccessToken,
|
||||
WorkspaceId: workspace.ID.String(),
|
||||
WorkspaceOwnerId: owner.ID.String(),
|
||||
TemplateName: template.Name,
|
||||
TemplateVersion: templateVersion.Name,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1295,6 +1307,51 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
|
||||
return nil
|
||||
}
|
||||
|
||||
// obtainOIDCAccessToken returns a valid OpenID Connect access token
|
||||
// for the user if it's able to obtain one, otherwise it returns an empty string.
|
||||
func obtainOIDCAccessToken(ctx context.Context, db database.Store, oidcConfig httpmw.OAuth2Config, userID uuid.UUID) (string, error) {
|
||||
link, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
|
||||
UserID: userID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get owner oidc link: %w", err)
|
||||
}
|
||||
|
||||
if link.OAuthExpiry.Before(database.Now()) && !link.OAuthExpiry.IsZero() && link.OAuthRefreshToken != "" {
|
||||
token, err := oidcConfig.TokenSource(ctx, &oauth2.Token{
|
||||
AccessToken: link.OAuthAccessToken,
|
||||
RefreshToken: link.OAuthRefreshToken,
|
||||
Expiry: link.OAuthExpiry,
|
||||
}).Token()
|
||||
if err != nil {
|
||||
// If OIDC fails to refresh, we return an empty string and don't fail.
|
||||
// There isn't a way to hard-opt in to OIDC from a template, so we don't
|
||||
// want to fail builds if users haven't authenticated for a while or something.
|
||||
return "", nil
|
||||
}
|
||||
link.OAuthAccessToken = token.AccessToken
|
||||
link.OAuthRefreshToken = token.RefreshToken
|
||||
link.OAuthExpiry = token.Expiry
|
||||
|
||||
link, err = db.UpdateUserLink(ctx, database.UpdateUserLinkParams{
|
||||
UserID: userID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
OAuthAccessToken: link.OAuthAccessToken,
|
||||
OAuthRefreshToken: link.OAuthRefreshToken,
|
||||
OAuthExpiry: link.OAuthExpiry,
|
||||
})
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("update user link: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return link.OAuthAccessToken, nil
|
||||
}
|
||||
|
||||
func convertValidationTypeSystem(typeSystem sdkproto.ParameterSchema_TypeSystem) (database.ParameterTypeSystem, error) {
|
||||
switch typeSystem {
|
||||
case sdkproto.ParameterSchema_None:
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package provisionerdserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/dbfake"
|
||||
"github.com/coder/coder/coderd/database/dbgen"
|
||||
)
|
||||
|
||||
func TestObtainOIDCAccessToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
t.Run("NoToken", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := dbfake.New()
|
||||
_, err := obtainOIDCAccessToken(ctx, db, nil, uuid.Nil)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
t.Run("InvalidConfig", func(t *testing.T) {
|
||||
// We still want OIDC to succeed even if exchanging the token fails.
|
||||
t.Parallel()
|
||||
db := dbfake.New()
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
dbgen.UserLink(t, db, database.UserLink{
|
||||
UserID: user.ID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
OAuthExpiry: database.Now().Add(-time.Hour),
|
||||
})
|
||||
_, err := obtainOIDCAccessToken(ctx, db, &oauth2.Config{}, user.ID)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
t.Run("Exchange", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := dbfake.New()
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
dbgen.UserLink(t, db, database.UserLink{
|
||||
UserID: user.ID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
OAuthExpiry: database.Now().Add(-time.Hour),
|
||||
})
|
||||
_, err := obtainOIDCAccessToken(ctx, db, &oauth2Config{
|
||||
tokenSource: func() (*oauth2.Token, error) {
|
||||
return &oauth2.Token{
|
||||
AccessToken: "token",
|
||||
}, nil
|
||||
},
|
||||
}, user.ID)
|
||||
require.NoError(t, err)
|
||||
link, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
|
||||
UserID: user.ID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "token", link.OAuthAccessToken)
|
||||
})
|
||||
}
|
||||
|
||||
type oauth2Config struct {
|
||||
tokenSource oauth2TokenSource
|
||||
}
|
||||
|
||||
func (o *oauth2Config) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource {
|
||||
return o.tokenSource
|
||||
}
|
||||
|
||||
func (*oauth2Config) AuthCodeURL(string, ...oauth2.AuthCodeOption) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (*oauth2Config) Exchange(context.Context, string, ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
|
||||
return &oauth2.Token{}, nil
|
||||
}
|
||||
|
||||
type oauth2TokenSource func() (*oauth2.Token, error)
|
||||
|
||||
func (o oauth2TokenSource) Token() (*oauth2.Token, error) {
|
||||
return o()
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/coderd/audit"
|
||||
@@ -100,6 +101,12 @@ func TestAcquireJob(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
user := dbgen.User(t, srv.Database, database.User{})
|
||||
link := dbgen.UserLink(t, srv.Database, database.UserLink{
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
UserID: user.ID,
|
||||
OAuthExpiry: database.Now().Add(time.Hour),
|
||||
OAuthAccessToken: "access-token",
|
||||
})
|
||||
template := dbgen.Template(t, srv.Database, database.Template{
|
||||
Name: "template",
|
||||
Provisioner: database.ProvisionerTypeEcho,
|
||||
@@ -208,15 +215,16 @@ func TestAcquireJob(t *testing.T) {
|
||||
},
|
||||
},
|
||||
Metadata: &sdkproto.Provision_Metadata{
|
||||
CoderUrl: srv.AccessURL.String(),
|
||||
WorkspaceTransition: sdkproto.WorkspaceTransition_START,
|
||||
WorkspaceName: workspace.Name,
|
||||
WorkspaceOwner: user.Username,
|
||||
WorkspaceOwnerEmail: user.Email,
|
||||
WorkspaceId: workspace.ID.String(),
|
||||
WorkspaceOwnerId: user.ID.String(),
|
||||
TemplateName: template.Name,
|
||||
TemplateVersion: version.Name,
|
||||
CoderUrl: srv.AccessURL.String(),
|
||||
WorkspaceTransition: sdkproto.WorkspaceTransition_START,
|
||||
WorkspaceName: workspace.Name,
|
||||
WorkspaceOwner: user.Username,
|
||||
WorkspaceOwnerEmail: user.Email,
|
||||
WorkspaceOwnerOidcAccessToken: link.OAuthAccessToken,
|
||||
WorkspaceId: workspace.ID.String(),
|
||||
WorkspaceOwnerId: user.ID.String(),
|
||||
TemplateName: template.Name,
|
||||
TemplateVersion: version.Name,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1152,6 +1160,7 @@ func setup(t *testing.T, ignoreLogErrors bool) *provisionerdserver.Server {
|
||||
return &provisionerdserver.Server{
|
||||
ID: uuid.New(),
|
||||
Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}),
|
||||
OIDCConfig: &oauth2.Config{},
|
||||
AccessURL: &url.URL{},
|
||||
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho},
|
||||
Database: db,
|
||||
|
||||
Reference in New Issue
Block a user