Files
mattermost/server/channels/api4/outgoing_oauth_connection.go
T
Ben SchumacherandClaude Sonnet 4.6 9d06c9de9a Fix missing return after c.Err in two api4 handlers (#37281)
* Fix missing return after c.Err in api4 handlers

Two handlers set c.Err but omitted a return, allowing subsequent code
to execute with an error already signalled:

- getJobs: invalid status passed to GetJobsByTypesAndStatuses DB query
- validateOutgoingOAuthConnectionCredentials: auditRec.Success() and
  ReturnStatusOK called even when token retrieval failed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Drop redundant w.WriteHeader in error branch

c.Err being set is sufficient; the framework writes the error response
after the handler returns. Calling w.WriteHeader explicitly would cause
a double-write.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Remove unused resultStatusCode variable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add integration test for getJobs with invalid status param

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix inconsistent error assertion in validate test

The invalid-credentials test was the only error-path check in the file
using httpRecorder.Code. All other error cases check c.Err.StatusCode,
which is consistent with the framework pattern (c.Err drives the HTTP
response; handlers do not write it directly).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add API-layer assertion to invalid validate test

The test previously only checked c.Err.StatusCode (internal handler
state). A regression that sets c.Err but fails to write the HTTP error
response would go undetected. Now the test also calls the endpoint
through the full API layer and asserts the HTTP response is not 200.

Mock matchers changed to mock.Anything so the expectation matches both
the direct handler call and the API-layer call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 09:12:46 +02:00

411 lines
16 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"github.com/mattermost/logr/v2"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
const (
whereOutgoingOAuthConnection = "outgoingOAuthConnections"
)
func (api *API) InitOutgoingOAuthConnection() {
api.BaseRoutes.OutgoingOAuthConnections.Handle("", api.APISessionRequired(listOutgoingOAuthConnections)).Methods(http.MethodGet)
api.BaseRoutes.OutgoingOAuthConnections.Handle("", api.APISessionRequired(createOutgoingOAuthConnection)).Methods(http.MethodPost)
api.BaseRoutes.OutgoingOAuthConnection.Handle("", api.APISessionRequired(getOutgoingOAuthConnection)).Methods(http.MethodGet)
api.BaseRoutes.OutgoingOAuthConnection.Handle("", api.APISessionRequired(updateOutgoingOAuthConnection)).Methods(http.MethodPut)
api.BaseRoutes.OutgoingOAuthConnection.Handle("", api.APISessionRequired(deleteOutgoingOAuthConnection)).Methods(http.MethodDelete)
api.BaseRoutes.OutgoingOAuthConnections.Handle("/validate", api.APISessionRequired(validateOutgoingOAuthConnectionCredentials)).Methods(http.MethodPost)
}
// checkOutgoingOAuthConnectionReadPermissions checks if the user has the permissions to read outgoing oauth connections.
// An user with the permissions to manage outgoing oauth connections can read outgoing oauth connections.
// Otherwise the user needs to have the permissions to manage outgoing webhooks or slash commands in order to read outgoing
// oauth connections so that they can use them.
// This is made in this way so only users with the management permission can setup the outgoing oauth connections and then
// other users can use them in their outgoing webhooks and slash commands if they have permissions to manage those.
func checkOutgoingOAuthConnectionReadPermissions(c *Context, teamId string) bool {
if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageOutgoingOAuthConnections) ||
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamId, model.PermissionManageOwnOutgoingWebhooks) ||
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamId, model.PermissionManageOwnSlashCommands) {
return true
}
c.SetPermissionError(model.PermissionManageOwnOutgoingWebhooks, model.PermissionManageOwnSlashCommands)
return false
}
// checkOutgoingOAuthConnectionWritePermissions checks if the user has the permissions to write outgoing oauth connections.
// This is a more granular permissions intended for system admins to manage (setup) outgoing oauth connections.
func checkOutgoingOAuthConnectionWritePermissions(c *Context) bool {
if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageOutgoingOAuthConnections) {
return true
}
c.SetPermissionError(model.PermissionManageOutgoingOAuthConnections)
return false
}
func ensureOutgoingOAuthConnectionInterface(c *Context, where string) (einterfaces.OutgoingOAuthConnectionInterface, bool) {
if c.App.Config().ServiceSettings.EnableOutgoingOAuthConnections != nil && !*c.App.Config().ServiceSettings.EnableOutgoingOAuthConnections {
c.Err = model.NewAppError(where, "api.context.outgoing_oauth_connection.not_available.configuration_disabled", nil, "", http.StatusNotImplemented)
return nil, false
}
if c.App.OutgoingOAuthConnections() == nil || !model.MinimumEnterpriseLicense(c.App.License()) {
c.Err = model.NewAppError(where, "api.license.upgrade_needed.app_error", nil, "", http.StatusNotImplemented)
return nil, false
}
return c.App.OutgoingOAuthConnections(), true
}
type listOutgoingOAuthConnectionsQuery struct {
FromID string
Limit int
Audience string
}
// SetDefaults sets the default values for the query.
func (q *listOutgoingOAuthConnectionsQuery) SetDefaults() {
// Set default values
if q.Limit == 0 {
q.Limit = 10
}
}
// IsValid validates the query.
func (q *listOutgoingOAuthConnectionsQuery) IsValid() error {
if q.Limit < 1 || q.Limit > 100 {
return fmt.Errorf("limit must be between 1 and 100")
}
return nil
}
// ToFilter converts the query to a filter that can be used to query the database.
func (q *listOutgoingOAuthConnectionsQuery) ToFilter() model.OutgoingOAuthConnectionGetConnectionsFilter {
return model.OutgoingOAuthConnectionGetConnectionsFilter{
OffsetId: q.FromID,
Limit: q.Limit,
Audience: q.Audience,
}
}
func NewListOutgoingOAuthConnectionsQueryFromURLQuery(values url.Values) (*listOutgoingOAuthConnectionsQuery, error) {
query := &listOutgoingOAuthConnectionsQuery{}
query.SetDefaults()
fromID := values.Get("from_id")
if fromID != "" {
query.FromID = fromID
}
limit := values.Get("limit")
if limit != "" {
limitInt, err := strconv.Atoi(limit)
if err != nil {
return nil, err
}
query.Limit = limitInt
}
audience := values.Get("audience")
if audience != "" {
query.Audience = audience
}
return query, nil
}
func listOutgoingOAuthConnections(c *Context, w http.ResponseWriter, r *http.Request) {
teamId := r.URL.Query().Get("team_id")
if !checkOutgoingOAuthConnectionReadPermissions(c, teamId) {
return
}
service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection)
if !ok {
return
}
query, err := NewListOutgoingOAuthConnectionsQueryFromURLQuery(r.URL.Query())
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.input_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
if errValid := query.IsValid(); errValid != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.input_error", nil, "", http.StatusBadRequest).Wrap(errValid)
return
}
var connections []*model.OutgoingOAuthConnection
if query.Audience != "" {
// If the consumer expects an audience match, use the `GetConnectionByAudience` method to
// retrieve a single connection.
connection, err := service.GetConnectionForAudience(c.AppContext, query.Audience)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
connections = append(connections, connection)
} else {
// If the consumer does not expect an audience match, use the `GetConnections` method to
// retrieve a list of connections that potentially matches the provided audience.
var errList *model.AppError
connections, errList = service.GetConnections(c.AppContext, query.ToFilter())
if errList != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.app_error", nil, "", http.StatusInternalServerError).Wrap(errList)
return
}
}
service.SanitizeConnections(connections)
if errJSON := json.NewEncoder(w).Encode(connections); errJSON != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.app_error", nil, "", http.StatusInternalServerError).Wrap(errJSON)
return
}
}
func getOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Request) {
if !checkOutgoingOAuthConnectionWritePermissions(c) {
return
}
service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection)
if !ok {
return
}
c.RequireOutgoingOAuthConnectionId()
connection, err := service.GetConnection(c.AppContext, c.Params.OutgoingOAuthConnectionID)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
service.SanitizeConnection(connection)
if err := json.NewEncoder(w).Encode(connection); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
}
func createOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventCreateOutgoingOauthConnection, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
if !checkOutgoingOAuthConnectionWritePermissions(c) {
return
}
service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection)
if !ok {
return
}
var inputConnection model.OutgoingOAuthConnection
if err := json.NewDecoder(r.Body).Decode(&inputConnection); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.create_connection.input_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
model.AddEventParameterAuditableToAuditRec(auditRec, "outgoing_oauth_connection", &inputConnection)
inputConnection.CreatorId = c.AppContext.Session().UserId
connection, err := service.SaveConnection(c.AppContext, &inputConnection)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.create_connection.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
auditRec.AddEventResultState(connection)
auditRec.AddEventObjectType("outgoing_oauth_connection")
c.LogAudit("client_id=" + connection.ClientId)
service.SanitizeConnection(connection)
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(connection); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.create_connection.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
}
func updateOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventUpdateOutgoingOAuthConnection, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "outgoing_oauth_connection_id", c.Params.OutgoingOAuthConnectionID)
c.LogAudit("attempt")
if !checkOutgoingOAuthConnectionWritePermissions(c) {
return
}
service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection)
if !ok {
return
}
c.RequireOutgoingOAuthConnectionId()
if c.Err != nil {
return
}
var inputConnection model.OutgoingOAuthConnection
if err := json.NewDecoder(r.Body).Decode(&inputConnection); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.update_connection.input_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
if inputConnection.Id != c.Params.OutgoingOAuthConnectionID {
c.SetInvalidParam("id")
return
}
currentConnection, err := service.GetConnection(c.AppContext, c.Params.OutgoingOAuthConnectionID)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.update_connection.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.AddEventPriorState(currentConnection)
currentConnection.Patch(&inputConnection)
connection, err := service.UpdateConnection(c.AppContext, currentConnection)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.update_connection.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.AddEventObjectType("outgoing_oauth_connection")
auditRec.AddEventResultState(connection)
auditRec.Success()
auditLogExtraInfo := "success"
// Audit log changes to clientID/Client Secret
if connection.ClientId != currentConnection.ClientId {
auditLogExtraInfo += " new_client_id=" + connection.ClientId
}
if connection.ClientSecret != currentConnection.ClientSecret {
auditLogExtraInfo += " new_client_secret"
}
c.LogAudit(auditLogExtraInfo)
service.SanitizeConnection(connection)
if err := json.NewEncoder(w).Encode(connection); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.update_connection.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
}
func deleteOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventDeleteOutgoingOAuthConnection, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "outgoing_oauth_connection_id", c.Params.OutgoingOAuthConnectionID)
c.LogAudit("attempt")
if !checkOutgoingOAuthConnectionWritePermissions(c) {
return
}
service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection)
if !ok {
return
}
c.RequireOutgoingOAuthConnectionId()
if c.Err != nil {
return
}
connection, err := service.GetConnection(c.AppContext, c.Params.OutgoingOAuthConnectionID)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.delete_connection.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.AddEventPriorState(connection)
if err := service.DeleteConnection(c.AppContext, c.Params.OutgoingOAuthConnectionID); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.delete_connection.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.AddEventObjectType("outgoing_oauth_connection")
auditRec.Success()
ReturnStatusOK(w)
}
// validateOutgoingOAuthConnectionCredentials validates the credentials of an outgoing oauth connection by requesting a token
// with the provided connection configuration. If the credentials are valid, the request will return a 200 status code and
// if the credentials are invalid, the request will return a 400 status code.
func validateOutgoingOAuthConnectionCredentials(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventValidateOutgoingOAuthConnectionCredentials, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
if !checkOutgoingOAuthConnectionWritePermissions(c) {
return
}
service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection)
if !ok {
return
}
// Allow checking connections sent in the body or by id if coming from an already existing
// connection url.
var inputConnection *model.OutgoingOAuthConnection
if err := json.NewDecoder(r.Body).Decode(&inputConnection); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.validate_connection_credentials.input_error", nil, "", http.StatusBadRequest).Wrap(err)
w.WriteHeader(c.Err.StatusCode)
return
}
if inputConnection.Id != "" && inputConnection.ClientSecret == "" {
var err *model.AppError
var storedConnection *model.OutgoingOAuthConnection
storedConnection, err = service.GetConnection(c.AppContext, inputConnection.Id)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.validate_connection_credentials.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
w.WriteHeader(c.Err.StatusCode)
return
}
inputConnection.ClientSecret = storedConnection.ClientSecret
}
model.AddEventParameterAuditableToAuditRec(auditRec, "outgoing_oauth_connection", inputConnection)
// Try to retrieve a token with the provided credentials
// do not store the token, just check if the credentials are valid and the request can be made
_, err := service.RetrieveTokenForConnection(c.AppContext, inputConnection)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.validate_connection_credentials.app_error", nil, "", err.StatusCode).Wrap(err)
c.Logger.Error("Failed to retrieve token while validating outgoing oauth connection", logr.Err(err))
return
}
auditRec.Success()
auditRec.AddEventResultState(inputConnection)
auditRec.AddEventObjectType("outgoing_oauth_connection")
ReturnStatusOK(w)
}