Webapp - Outgoing OAuth Connections (#25507)

* added store

* make generated

* add missing license headers

* fix receiver name

* i18n

* i18n sorting

* update migrations from master

* make migrations-extract

* update retrylayer tests

* replaced sql query with id pagination

* fixed flaky tests

* missing columns

* missing columns on save/update

* typo

* improved tests

* remove enum from mysql colum

* add password credentials to store

* license changes

* OAuthOutgoingConnectionInterface

* Oauth -> OAuth

* make generated

* copied over installed_oauth_apps component and renamed things to installed_outgoing_oauth_connections

* merge migrations

* renamed migrations

* model change suggestions

* refactor test functionsn

* migration typo

* refactor store table names

* updated sanitize test

* cleanup merge

* refactor symbol

* "installed outgoing oauth connections" page works

* move things into a nested folder

* add and edit page stubs work

* list endpoint

* oauthoutgoingconnection -> outgoingoauthconnection

* signature change

* i18n update

* granttype typo

* naming

* api list

* uppercase typo

* i18n

* missing license header

* fixed path in comments

* updated openapi definitions

* changes to support selecting command request url

* sanitize connections

* make generated

* test license and no feature flag

* removed t.fatal

* updated testhelper calls

* yaml schema fixes

* switched interface name

* suggested translation

* missing i18n translation

* management permission

* moved permission initalization to proper place

* endpoints

* put tests

* error check typo

* fixed specific enttity urls

* tests

* read permission check

* updated openapi definitions

* i18n

* GetConnectionByAudience method

* notes

* replaced GetConnectionsByAudience with a filter

* added custom oauth token object

* updated interface and usage

* properly set enterprise interface

* move retrieval logic to impl

* webhook tests

* translations

* i18n: updates

* address comments

* endpoint and tests

* i18n

* api docs

* fixed endpoint path

* sq.like

* use filter object instead of parameters

* set url values if not empty

* typos

* converted some components to function components, and move around files

* correctly check token url

* restore flag to previous value

* added command oauth handler

* update enterprise imports

* migrate last component to function component

* Added enterprise import

* refactor permissions and add necessary webapp code

* Check correct flag in permission tree

* allow partial updates

* sort i18n webapp

* missing test modification

* fixed webapp i18n sorting

* allow validating stored connections

* added missing translation

* fix finished adding connection link and text on result page

* added missing permission to smoke tests

* missing role in smoke test

* updated translations

* updated translations

* support editing client secret on existing connection

* fix some i18n strings

* updated translations

* better error messages

* progress on using react select for command request url while maintaining typed in value

* remove writeheader, test

* HasValidGrantType

* end early to avoid nil pointer errors

* move slash command request url input box into its own component

* wrap components related to oauth connections in config check

* fix tests

* i18n-extract

* change some i18n strings to say "Outgoing OAuth 2.0 Connections"

* remove debug code

* fixed i18n

* updated i18n file

* feature configuration backend

* typo

* add system console setting

* Revert "typo"

This reverts commit 669da23e8ee47525ccaa6f59cbbd20bf8a121191.

* Revert "updated i18n file"

This reverts commit d0882c0dd7587533f0d0f7a7b7b190684186158a.

* Revert "fixed i18n"

This reverts commit 3108866bc19139182dfd094921c56cdefc4695ea.

* fixed i18n

* updated i18n file

* typo

* updated i18n

* updated i18n

* updated i18n

* updated version to 9.6

* replace feature flag with system console configuration

* i18n

* updated tests

* pr feedback

* fix styling of disabled text box

* fix styling of action links in integration console

* server changes for validation feature

* webapp changes for validation feature

* pencil icon styling

* styling fixes for oauth audience correct configuration message

* fix sanitize test

* remove max lengths from outgoing oauth connection form

* use config var in webapp instead of feature flag

* change asterisks to bullets

* update api docs for validate endpoint

* feedback from ux review

* fix lint, types, tests

* fix stylelint

* implement validation button under the token url input

* support wildcard for matching audience urls

* updates for styling

* update snapshots

* add doc links for the outgoing oauth connections feature

* change doc links to use permalink

* add docs link to system console

* fix: use limitedreader in json decoding

* fix: form error in validation

* management permission can read now

* updated api documentation

* doc typo

* require one permission to read only

* fix api connection list audience filter

* fix audience matching and add loading indicator

* fix team permissions on outgoing oauth connection api calls

* fix api doc and test, for adding team id to query params

* handle read permissions by adding a team in the payload

* missing teamid query parameter in test

* change validate button logic to not require audience urls to be filled out

* fix redux type

---------

Co-authored-by: Felipe Martin <me@fmartingr.com>
Этот коммит содержится в:
Michael Kochell
2024-02-09 14:49:49 -05:00
коммит произвёл GitHub
родитель 3f6c94cfc3
Коммит 4e071e861c
91 изменённых файлов: 10116 добавлений и 201 удалений

Просмотреть файл

@@ -485,7 +485,7 @@ func (c *Client4) outgoingOAuthConnectionsRoute() string {
}
func (c *Client4) outgoingOAuthConnectionRoute(id string) string {
return fmt.Sprintf("/oauth/outgoing_connections/%s", id)
return fmt.Sprintf("%s/%s", c.outgoingOAuthConnectionsRoute(), id)
}
func (c *Client4) jobsRoute() string {
@@ -6023,8 +6023,8 @@ func (c *Client4) GetOAuthAccessToken(ctx context.Context, data url.Values) (*Ac
// OutgoingOAuthConnection section
// GetOutgoingOAuthConnections retrieves the outgoing OAuth connections.
func (c *Client4) GetOutgoingOAuthConnections(ctx context.Context, fromID string, limit int) ([]*OutgoingOAuthConnection, *Response, error) {
r, err := c.DoAPIGet(ctx, c.outgoingOAuthConnectionsRoute(), "")
func (c *Client4) GetOutgoingOAuthConnections(ctx context.Context, filters OutgoingOAuthConnectionGetConnectionsFilter) ([]*OutgoingOAuthConnection, *Response, error) {
r, err := c.DoAPIGet(ctx, c.outgoingOAuthConnectionsRoute()+"?"+filters.ToURLValues().Encode(), "")
if err != nil {
return nil, BuildResponse(r), err
}
@@ -6050,6 +6050,53 @@ func (c *Client4) GetOutgoingOAuthConnection(ctx context.Context, id string) (*O
return connection, BuildResponse(r), nil
}
// DeleteOutgoingOAuthConnection deletes the outgoing OAuth connection with the given ID.
func (c *Client4) DeleteOutgoingOAuthConnection(ctx context.Context, id string) (*Response, error) {
r, err := c.DoAPIDelete(ctx, c.outgoingOAuthConnectionRoute(id))
if err != nil {
return BuildResponse(r), err
}
defer closeBody(r)
return BuildResponse(r), nil
}
// UpdateOutgoingOAuthConnection updates the outgoing OAuth connection with the given ID.
func (c *Client4) UpdateOutgoingOAuthConnection(ctx context.Context, connection *OutgoingOAuthConnection) (*OutgoingOAuthConnection, *Response, error) {
buf, err := json.Marshal(connection)
if err != nil {
return nil, nil, NewAppError("UpdateOutgoingOAuthConnection", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
r, err := c.DoAPIPutBytes(ctx, c.outgoingOAuthConnectionRoute(connection.Id), buf)
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
var resultConnection OutgoingOAuthConnection
if err := json.NewDecoder(r.Body).Decode(&resultConnection); err != nil {
return nil, nil, NewAppError("UpdateOutgoingOAuthConnection", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return &resultConnection, BuildResponse(r), nil
}
// CreateOutgoingOAuthConnection creates a new outgoing OAuth connection.
func (c *Client4) CreateOutgoingOAuthConnection(ctx context.Context, connection *OutgoingOAuthConnection) (*OutgoingOAuthConnection, *Response, error) {
buf, err := json.Marshal(connection)
if err != nil {
return nil, nil, NewAppError("CreateOutgoingOAuthConnection", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
r, err := c.DoAPIPostBytes(ctx, c.outgoingOAuthConnectionsRoute(), buf)
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
var resultConnection OutgoingOAuthConnection
if err := json.NewDecoder(r.Body).Decode(&resultConnection); err != nil {
return nil, nil, NewAppError("CreateOutgoingOAuthConnection", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return &resultConnection, BuildResponse(r), nil
}
// Elasticsearch Section
// TestElasticsearch will attempt to connect to the configured Elasticsearch server and return OK if configured.

Просмотреть файл

@@ -312,6 +312,7 @@ type ServiceSettings struct {
EnableOAuthServiceProvider *bool `access:"integrations_integration_management"`
EnableIncomingWebhooks *bool `access:"integrations_integration_management"`
EnableOutgoingWebhooks *bool `access:"integrations_integration_management"`
EnableOutgoingOAuthConnections *bool `access:"integrations_integration_management"`
EnableCommands *bool `access:"integrations_integration_management"`
OutgoingIntegrationRequestsTimeout *int64 `access:"integrations_integration_management"` // In seconds.
EnablePostUsernameOverride *bool `access:"integrations_integration_management"`
@@ -515,6 +516,10 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) {
s.EnableOutgoingWebhooks = NewBool(true)
}
if s.EnableOutgoingOAuthConnections == nil {
s.EnableOutgoingOAuthConnections = NewBool(false)
}
if s.OutgoingIntegrationRequestsTimeout == nil {
s.OutgoingIntegrationRequestsTimeout = NewInt64(OutgoingIntegrationRequestsDefaultTimeout)
}

Просмотреть файл

@@ -50,8 +50,6 @@ type FeatureFlags struct {
ConsumePostHook bool
CloudAnnualRenewals bool
OutgoingOAuthConnections bool
}
func (f *FeatureFlags) SetDefaults() {
@@ -71,7 +69,6 @@ func (f *FeatureFlags) SetDefaults() {
f.CloudIPFiltering = false
f.ConsumePostHook = false
f.CloudAnnualRenewals = false
f.OutgoingOAuthConnections = false
}
// ToMap returns the feature flags as a map[string]string

Просмотреть файл

@@ -46,4 +46,5 @@ const (
MigrationKeyDeleteEmptyDrafts = "delete_empty_drafts_migration"
MigrationKeyDeleteOrphanDrafts = "delete_orphan_drafts_migration"
MigrationKeyAddIPFilteringPermissions = "add_ip_filtering_permissions"
MigrationKeyAddOutgoingOAuthConnectionsPermissions = "add_outgoing_oauth_connections_permissions"
)

Просмотреть файл

@@ -4,7 +4,9 @@
package model
import (
"fmt"
"net/http"
"net/url"
"unicode/utf8"
)
@@ -49,12 +51,42 @@ func (oa *OutgoingOAuthConnection) Auditable() map[string]interface{} {
// Sanitize removes any sensitive fields from the OutgoingOAuthConnection object.
func (oa *OutgoingOAuthConnection) Sanitize() {
oa.ClientId = ""
oa.ClientSecret = ""
oa.CredentialsUsername = nil
oa.CredentialsPassword = nil
}
// Patch updates the OutgoingOAuthConnection object with the non-empty fields from the given connection.
func (oa *OutgoingOAuthConnection) Patch(conn *OutgoingOAuthConnection) {
if conn == nil {
return
}
if conn.Name != "" {
oa.Name = conn.Name
}
if conn.ClientId != "" {
oa.ClientId = conn.ClientId
}
if conn.ClientSecret != "" {
oa.ClientSecret = conn.ClientSecret
}
if conn.OAuthTokenURL != "" {
oa.OAuthTokenURL = conn.OAuthTokenURL
}
if conn.GrantType != "" {
oa.GrantType = conn.GrantType
}
if len(conn.Audiences) > 0 {
oa.Audiences = conn.Audiences
}
if conn.CredentialsUsername != nil {
oa.CredentialsUsername = conn.CredentialsUsername
}
if conn.CredentialsPassword != nil {
oa.CredentialsPassword = conn.CredentialsPassword
}
}
// IsValid validates the object and returns an error if it isn't properly configured
func (oa *OutgoingOAuthConnection) IsValid() *AppError {
if !IsValidId(oa.Id) {
@@ -85,11 +117,11 @@ func (oa *OutgoingOAuthConnection) IsValid() *AppError {
return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.client_secret.error", nil, "id="+oa.Id, http.StatusBadRequest)
}
if oa.OAuthTokenURL == "" || utf8.RuneCountInString(oa.OAuthTokenURL) > 256 {
if !IsValidHTTPURL(oa.OAuthTokenURL) || utf8.RuneCountInString(oa.OAuthTokenURL) > 256 {
return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.oauth_token_url.error", nil, "id="+oa.Id, http.StatusBadRequest)
}
if err := oa.IsValidGrantType(); err != nil {
if err := oa.HasValidGrantType(); err != nil {
return err
}
@@ -100,7 +132,7 @@ func (oa *OutgoingOAuthConnection) IsValid() *AppError {
if len(oa.Audiences) > 0 {
for _, audience := range oa.Audiences {
if !IsValidHTTPURL(audience) {
return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.audience.error", nil, "id="+oa.Id, http.StatusBadRequest)
return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.audience.error", map[string]any{"Url": audience}, "id="+oa.Id, http.StatusBadRequest)
}
}
}
@@ -108,8 +140,8 @@ func (oa *OutgoingOAuthConnection) IsValid() *AppError {
return nil
}
// IsValidGrantType validates the grant type and its parameters returning an error if it isn't properly configured
func (oa *OutgoingOAuthConnection) IsValidGrantType() *AppError {
// HasValidGrantType validates the grant type and its parameters returning an error if it isn't properly configured
func (oa *OutgoingOAuthConnection) HasValidGrantType() *AppError {
if !oa.GrantType.IsValid() {
return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.grant_type.error", nil, "id="+oa.Id, http.StatusBadRequest)
}
@@ -149,6 +181,12 @@ func (oa *OutgoingOAuthConnection) Etag() string {
type OutgoingOAuthConnectionGetConnectionsFilter struct {
OffsetId string
Limit int
Audience string
// TeamId is not used as a filter but as a way to check if the current user has permission to
// access the outgoing oauth connection for the given team in order to use them in the slash
// commands and outgoing webhooks.
TeamId string
}
// SetDefaults sets the default values for the filter
@@ -157,3 +195,36 @@ func (oaf *OutgoingOAuthConnectionGetConnectionsFilter) SetDefaults() {
oaf.Limit = defaultGetConnectionsLimit
}
}
// ToURLValues converts the filter to url.Values
func (oaf *OutgoingOAuthConnectionGetConnectionsFilter) ToURLValues() url.Values {
v := url.Values{}
if oaf.Limit > 0 {
v.Set("limit", fmt.Sprintf("%d", oaf.Limit))
}
if oaf.OffsetId != "" {
v.Set("offset_id", oaf.OffsetId)
}
if oaf.Audience != "" {
v.Set("audience", oaf.Audience)
}
if oaf.TeamId != "" {
v.Set("team_id", oaf.TeamId)
}
return v
}
// OutgoingOAuthConnectionToken is used to return the token for an outgoing connection oauth
// authentication request
type OutgoingOAuthConnectionToken struct {
AccessToken string
TokenType string
}
func (ooct *OutgoingOAuthConnectionToken) AsHeaderValue() string {
return ooct.TokenType + " " + ooct.AccessToken
}

Просмотреть файл

@@ -13,16 +13,18 @@ var (
func newValidOutgoingOAuthConnection() *OutgoingOAuthConnection {
return &OutgoingOAuthConnection{
Id: NewId(),
CreatorId: NewId(),
Name: "Test Connection",
ClientId: NewId(),
ClientSecret: NewId(),
OAuthTokenURL: "https://nowhere.com/oauth/token",
GrantType: OutgoingOAuthConnectionGrantTypeClientCredentials,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
Audiences: []string{"https://nowhere.com"},
Id: NewId(),
CreatorId: NewId(),
Name: "Test Connection",
ClientId: NewId(),
ClientSecret: NewId(),
CredentialsUsername: NewString(NewId()),
CredentialsPassword: NewString(NewId()),
OAuthTokenURL: "https://nowhere.com/oauth/token",
GrantType: OutgoingOAuthConnectionGrantTypeClientCredentials,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
Audiences: []string{"https://nowhere.com"},
}
}
@@ -318,8 +320,66 @@ func TestOutgoingOAuthConnectionSanitize(t *testing.T) {
oa := newValidOutgoingOAuthConnection()
oa.Sanitize()
require.Empty(t, oa.ClientId)
require.NotEmpty(t, oa.ClientId)
require.Empty(t, oa.ClientSecret)
require.Empty(t, oa.CredentialsUsername)
require.NotEmpty(t, oa.CredentialsUsername)
require.Empty(t, oa.CredentialsPassword)
}
func TestOutgoingOAuthConnectionPatch(t *testing.T) {
t.Run("name", func(t *testing.T) {
oa := newValidOutgoingOAuthConnection()
oa.Patch(&OutgoingOAuthConnection{Name: "new name"})
require.Equal(t, "new name", oa.Name)
})
t.Run("client_id", func(t *testing.T) {
oa := newValidOutgoingOAuthConnection()
oa.Patch(&OutgoingOAuthConnection{ClientId: "new client id"})
require.Equal(t, "new client id", oa.ClientId)
})
t.Run("client_secret", func(t *testing.T) {
oa := newValidOutgoingOAuthConnection()
oa.Patch(&OutgoingOAuthConnection{ClientSecret: "new client secret"})
require.Equal(t, "new client secret", oa.ClientSecret)
})
t.Run("oauth_token_url", func(t *testing.T) {
oa := newValidOutgoingOAuthConnection()
oa.Patch(&OutgoingOAuthConnection{OAuthTokenURL: "new oauth token url"})
require.Equal(t, "new oauth token url", oa.OAuthTokenURL)
})
t.Run("grant_type", func(t *testing.T) {
oa := newValidOutgoingOAuthConnection()
oa.Patch(&OutgoingOAuthConnection{GrantType: OutgoingOAuthConnectionGrantTypePassword})
require.Equal(t, OutgoingOAuthConnectionGrantTypePassword, oa.GrantType)
})
t.Run("audiences", func(t *testing.T) {
oa := newValidOutgoingOAuthConnection()
oa.Patch(&OutgoingOAuthConnection{Audiences: StringArray{"new audience"}})
require.Equal(t, StringArray{"new audience"}, oa.Audiences)
})
t.Run("credentials_username", func(t *testing.T) {
oa := newValidOutgoingOAuthConnection()
oa.Patch(&OutgoingOAuthConnection{CredentialsUsername: &someString})
require.Equal(t, &someString, oa.CredentialsUsername)
})
t.Run("credentials_password", func(t *testing.T) {
oa := newValidOutgoingOAuthConnection()
oa.Patch(&OutgoingOAuthConnection{CredentialsPassword: &someString})
require.Equal(t, &someString, oa.CredentialsPassword)
})
}

Просмотреть файл

@@ -388,6 +388,8 @@ var ChannelModeratedPermissionsMap map[string]string
var SysconsoleReadPermissions []*Permission
var SysconsoleWritePermissions []*Permission
var PermissionManageOutgoingOAuthConnections *Permission
func initializePermissions() {
PermissionInviteUser = &Permission{
"invite_user",
@@ -2125,6 +2127,13 @@ func initializePermissions() {
PermissionScopeSystem,
}
PermissionManageOutgoingOAuthConnections = &Permission{
"manage_outgoing_oauth_connections",
"authentication.permissions.manage_outgoing_oauth_connections.name",
"authentication.permissions.manage_outgoing_oauth_connections.description",
PermissionScopeSystem,
}
SysconsoleReadPermissions = []*Permission{
PermissionSysconsoleReadAboutEditionAndLicense,
PermissionSysconsoleReadBilling,
@@ -2317,6 +2326,7 @@ func initializePermissions() {
PermissionReadLicenseInformation,
PermissionManageLicenseInformation,
PermissionCreateCustomGroup,
PermissionManageOutgoingOAuthConnections,
}
TeamScopedPermissions := []*Permission{

Просмотреть файл

@@ -342,6 +342,7 @@ func init() {
PermissionSysconsoleWriteIntegrationsCors.Id,
PermissionSysconsoleReadProductsBoards.Id,
PermissionSysconsoleWriteProductsBoards.Id,
PermissionManageOutgoingOAuthConnections.Id,
}
SystemCustomGroupAdminDefaultPermissions = []string{