mirror of
https://github.com/mattermost/mattermost.git
synced 2026-09-21 05:54:10 +08:00
* 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>
411 lines
16 KiB
Go
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)
|
|
}
|